Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 51 additions & 19 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,22 +157,42 @@ function scheduleSpontaneousBlink() {

// ---------- Active tab tracking ----------
// Unsupported pages (PDFs, chrome://, Web Store) can't run a content script,
// so we detect "active tab is unsupported" as "the currently active tab has
// no connected eyeball port". When that happens the eye centers - the same
// behavior as before this change.
// so the eye centers on them. We used to infer "unsupported" from "the active
// tab has no connected eyeball port", but port presence is unreliable across
// service-worker restarts (portsByTab is wiped when the SW idles out) - which
// caused supported tabs to be misread as unsupported and centered on cold start.
// Instead we measure support directly from the active tab's URL.
let activeTabId = null;
const portsByTab = new Map(); // tabId -> Port

// A page is unsupported when a content script can't run and stream gaze there.
// Approximate from the URL: ordinary http/https pages are supported; chrome://,
// file://, data:, the new-tab page, the PDF viewer, and the Chrome Web Store
// (https but content-script-blocked) are not. A missing URL (no host permission,
// e.g. chrome:// tabs) also counts as unsupported, which is what we want.
function isUnsupportedUrl(url) {
if (!url) return true;
if (!/^https?:\/\//i.test(url)) return true;
if (/^https:\/\/(chromewebstore\.google\.com|chrome\.google\.com\/webstore)\b/i.test(url)) return true;
return false;
}

let centerCheckTimer = null;
function scheduleCenterCheck(delayMs = 200) {
// Brief grace period: switching to a previously-visited supported tab is
// instant (its port already exists), but a freshly-opened tab needs ~tens
// of ms for its content script to connect. Wait, then re-check.
// Small debounce so rapid tab switches don't each fire a tabs.get; then read
// the active tab's URL and center only if it's genuinely unsupported.
if (centerCheckTimer) clearTimeout(centerCheckTimer);
centerCheckTimer = setTimeout(() => {
centerCheckTimer = setTimeout(async () => {
centerCheckTimer = null;
if (activeTabId == null) return;
if (!portsByTab.has(activeTabId)) setOpenPose(BUCKET_CENTER);
const tabId = activeTabId;
if (tabId == null) return;
try {
const tab = await chrome.tabs.get(tabId);
// The active tab may have changed while we awaited; re-check before acting.
if (activeTabId === tabId && isUnsupportedUrl(tab.url)) setOpenPose(BUCKET_CENTER);
} catch {
// Tab closed between scheduling and the check; ignore.
}
}, delayMs);
}

Expand All @@ -183,6 +203,10 @@ chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "eyeball") return;
const tabId = port.sender?.tab?.id ?? null;
if (tabId != null) portsByTab.set(tabId, port);
// Ack the connection. The content script uses any inbound message to confirm
// the port is live and reset its staleness timer, so it never sits on a dead
// port silently dropping gaze (the freeze-after-idle bug).
try { port.postMessage({ type: "hello" }); } catch { /* tab already gone */ }

port.onMessage.addListener((msg) => {
if (!msg || typeof msg !== "object") return;
Expand All @@ -196,8 +220,11 @@ chrome.runtime.onConnect.addListener((port) => {
// upward angle to its downward mirror as a defensive backstop.
if (Math.sin(angle) < 0) angle = -angle;
const bucket = angleToBucket(angle, mag);
// Gaze proves the window is active; recover from a stale sleeping.
if (mode === "sleeping") mode = "open";
// Gaze proves the window is active and the eye should be tracking, so
// recover from any stale non-open mode: a leftover 'sleeping', or a
// 'blinking' whose 140ms reopen timer was lost to an SW shutdown (which
// would otherwise keep the eye shut and ignore every gaze).
if (mode !== "open") mode = "open";
setOpenPose(bucket);
// Keep the calm spontaneous-blink rhythm going while tracking.
lastGazeAt = Date.now();
Expand Down Expand Up @@ -268,24 +295,29 @@ chrome.tabs.query({ active: true, lastFocusedWindow: true }).then((tabs) => {
// Idle heartbeat: a soft blink ~once a minute so the icon still feels alive
// when nobody is interacting (the fast spontaneous-blink loop above only runs
// while tracking). 1 minute is the alarms API minimum, which is exactly what
// we want here - rare wake-ups, negligible cost. The Chrome Action toolbar
// icon persists across service-worker restarts, so we run init at MODULE LEVEL
// (not gated on onInstalled/onStartup) - this guarantees that an SW which died
// with the icon in BUCKET_CLOSED comes back open on its next wake instead of
// staying stuck closed.
// we want here - rare wake-ups, negligible cost. We run init at MODULE LEVEL
// (not gated on onInstalled/onStartup) so every SW wake re-arms the alarm.
const BLINK_ALARM = "eyeball-blink";

// Paint the first frame in the palette the clock implies, so a fresh wake never
// flashes the wrong theme.
// Pick the palette the clock implies so a fresh wake never flashes the wrong
// theme. We do NOT force-center here: the toolbar icon persists across SW
// restarts, so forcing center on every cold start would yank a supported tab's
// eye to neutral each time the SW idled out and woke. Instead the active-tab
// probe (above) centers only when the tab is actually unsupported, and a stale
// CLOSED frame (SW died mid-blink/sleep) is reopened by the next gaze or by the
// blink alarm below - both now recover a stranded mode.
theme = scheduledTheme();
setBucket(BUCKET_CENTER, { force: true });

chrome.alarms.create(BLINK_ALARM, { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== BLINK_ALARM) return;
// The 1-minute heartbeat is also our day/night crossing check: re-evaluate
// the schedule, then do the idle blink.
evaluateTheme();
// A real blink lasts 140ms; if we still see 'blinking' a minute later, its
// reopen timer was lost to an SW shutdown - recover so the eye doesn't stay
// shut (blinkOnce is a no-op unless mode is 'open').
if (mode === "blinking") mode = "open";
blinkOnce();
});

Expand Down
50 changes: 44 additions & 6 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,65 @@
const EYE_ABOVE_FRAC = 0.25; // eye height above the viewport top, as a fraction of viewport height. Smaller = turns more sharply to follow; larger = calmer, flatter gaze.
const EYE_X_FRAC = 1.0; // eye horizontal position as a fraction of width; 1 = right edge, where Chrome's toolbar icons live. Lower it if your icon is pinned further left.

// A long-lived Port carries gaze/focus events to the service worker. The MV3
// catch: the SW idles out after ~30s of quiet, which kills this port - and the
// content-side `onDisconnect` is not guaranteed to fire, so we can end up
// holding a *dead* port whose `postMessage` neither throws nor delivers. That is
// the "eye freezes and never recovers" bug: gaze is dropped silently forever.
//
// Guard against it two ways: rebuild a port that has been quiet long enough that
// the SW may have idled out underneath it, and tear the port down on any send
// failure. We deliberately do NOT ping on a timer - that would pin the SW alive
// against its idle-out design. Reconnecting lazily on the next real event (a
// mouse move, a focus change) is enough to recover, and a fresh connect wakes
// the SW anyway.
let port = null;
function ensurePort() {
if (port) return port;
let lastContactAt = 0; // last time we sent or received anything on `port`
const PORT_STALE_MS = 20000; // treat as stale past this; SW idles out around 30s

function teardownPort() {
if (port) {
try { port.disconnect(); } catch { /* already gone */ }
}
port = null;
}

function connectPort() {
try {
port = chrome.runtime.connect({ name: "eyeball" });
port.onDisconnect.addListener(() => {
port = null;
});
lastContactAt = Date.now();
// Any inbound message (the SW's "hello" ack) proves the port is live and
// refreshes the staleness clock.
port.onMessage.addListener(() => { lastContactAt = Date.now(); });
port.onDisconnect.addListener(() => { port = null; });
} catch {
// chrome.runtime is gone - an orphaned content script after an extension
// reload/update. Nothing to reconnect to; a tab reload re-injects us fresh.
port = null;
}
return port;
}

function ensurePort() {
// Rebuild when we have no port, or when the current one has gone quiet long
// enough that the SW may have died underneath it. The rebuild is cheap and
// idempotent, so erring toward reconnecting is safe.
if (!port || Date.now() - lastContactAt > PORT_STALE_MS) {
teardownPort();
return connectPort();
}
return port;
}

function send(msg) {
const p = ensurePort();
if (!p) return;
try {
p.postMessage(msg);
lastContactAt = Date.now();
} catch {
port = null;
// The port was dead after all - drop it so the next event reconnects.
teardownPort();
}
}

Expand Down
Loading