Extension
Chrome Manifest V3 extension. Version 0.15.0, shipped in two variants (see
Build variants).
Entry Points
| File | Role |
|---|---|
content.ts |
Injected into every page, captures DOM events |
background.ts |
Service worker, manages sessions and WebSocket |
popup.ts |
Extension popup with tabs (Status, Trails, About) |
sidepanel.ts |
Side panel with trail replay engine |
trail-db.ts |
IndexedDB wrapper for local trail persistence |
config.ts |
Constants (host URL, throttle values) |
types.ts |
Shared TypeScript interfaces |
blincr-picker.ts |
"Blincr this element" picker + create form |
capture-policy.ts |
Capture boundary: deny/nav-only classification, URL auth-param stripping, sensitive-field rules (pure, unit-tested) |
crypto.ts |
E12.3.1 keyring: Web Crypto AES-GCM content keys, per-trail HKDF derivation, recovery-code export/import |
transform.ts |
E12.7 locality-ladder resolver for future Lens AI transforms (P8/E8.3, unbuilt) — no dispatcher yet, nothing to dispatch to |
Content Script
Initializes once per page (guards with __blincr_injected flag). Registers:
- Click capture — Records element tag, text, href, CSS selector path
- Scroll capture — Throttled by
SCROLL_THROTTLE_MS, records scroll position - Selection capture — Fires on
selectionchange, records selected text - Clipboard capture — Tracks copy events with copied text content
- SPA navigation — Listens to
popstateandHistoryAPI monkey-patches - Overlay shell — Injects the overlay manager container into the page
Events are sent to background via chrome.runtime.sendMessage as BROWSE_EVENT.
Background Service Worker
Manages session lifecycle:
START_SESSION— Creates a new DO session, connects WebSocketJOIN_SESSION— Joins existing session by IDSTOP_SESSION— Disconnects WebSocket, saves trail locallyGET_STATUS— Returns current session stateBROWSE_EVENT— Forwards event to WebSocket (if live) and stores locallyGET_TRAIL— Returns current trail from memoryREPLAY_COMMAND— Forwards replay controls to side panelADD_WAYPOINT— Inserts a named waypoint event into the trailGET_FINGERPRINT— Returns persistent install ID fromchrome.storage.localGET_OVERLAYS/ACTIVATE_COMMENT_MODE— Overlay plugin coordinationCREATE_BLINCR— AuthenticatedPOST /api/user/blincrs(see Blincrs below)
Capture boundary (E12.2)
The cheapest data to protect is data never captured. Every page gets one of
three capture modes, decided in capture-policy.ts (pure module, tested in
tests/capture-policy.test.ts) and enforced at two layers — the content
script attaches no capture listeners at all on protected pages (pause is
physical), and background.ts's sendBrowseEvent re-checks the policy so no
relay path can bypass it:
deny— built-in categories (banking & payments, health portals, government & identity, adult) plus any site the user marked "Never capture here" in the popup. Nothing is captured; during an active session a dismissible "capture paused" badge shows the boundary working.nav_only— personal-workspace domains (webmail, chat: Gmail, Outlook, WhatsApp Web, Slack, Teams, …) where page content is largely other people's data. Only URL + title navigation events are captured — no fingerprint, clicks, scrolls, selections, or clipboard.full— normal capture, hardened: auth-shaped query/fragment params (token,access_token,sig,api_key,code, …) and URL userinfo are stripped from every captured URL; clicks on password / hidden /cc-*/ one-time-code fields record only{x, y, redacted: true}; and INPUT/TEXTAREA/contenteditable text never enters click payloads.
Settings live in chrome.storage.local (blincr_capture_policy): user
never-capture sites, extra workspace domains, disabled categories. The popup
shows the current tab's classification and a one-click "Never capture on
storage.onChanged, the content script on next injection.
Blincrs ("blincr this element")
A blincr is a standing page watcher. Signed-in users get cloud blincrs
(account-owned, run 24/7 by the watch-worker cron); signed-out users get
local blincrs (E14.1 — see below), which run entirely in the browser.
Entry point: popup button "👁 Blincr this
element" → chrome.tabs.sendMessage(tabId, { kind: "ACTIVATE_BLINCR_MODE" })
→ content.ts → blincr-picker.ts.
- Picker (
activateBlincrPicker()): hover-highlights the element under the cursor; a click builds a CSS selector (reusingcontent.ts'sbuildSelector), guesses a recipe (price-looking text →ltcomparator with a threshold at 90% of the parsed value; otherwisechanged), and opens a small form (label, comparator, threshold, check frequency). - Create: the form sends
{ kind: "CREATE_BLINCR", input }tobackground.ts, which callscreateBlincrRemote().
Auth bridge (why cookies permission is needed)
The extension has no session of its own — it piggybacks the user's existing
blincr.com login. The dashboard's session cookie is HttpOnly +
SameSite=Lax, which the browser never attaches to a chrome-extension://
origin's fetch (that origin counts as cross-site). So background.ts reads
the cookie value directly via chrome.cookies.get({ url: API_BASE, name: "blincr_session" }) — the cookies permission plus the existing
<all_urls> host permission make this a same-product read, not a
third-party leak — and forwards it as an X-Blincr-Ext-Session header.
getSessionToken() in src/ar.ts accepts either the cookie or this header.
If no cookie is found (user not logged in on blincr.com), the watcher is
saved locally instead (see below) — the toast explains the trade-off.
Local blincrs (E14.1 — autonomy ladder L0)
See docs/autonomy-ladder.md for the full doctrine. In the picker, every
watcher gets an explicit "Run where?" choice — "In this browser
(private, only while open)" (default) or "On blincr.com (24/7, needs
sign-in)" — with the trade-off shown live underneath. background.ts
(handleCreateBlincr) honors that choice: local always saves in-browser;
cloud requires sign-in and returns a named error
(blincr.watch.not_authenticated) rather than silently downgrading to
local. Older callers that send no choice keep the original
best-effort-cloud-else-local fallback.
- Engine: the shared platform-neutral core
(
extension/src/blincr-engine-core.ts— compare, intervals, regex/JSON-LD extraction; the workers re-export it viasrc/blincr-engine.ts). Local state transitions (extension/src/blincr-local.ts, pure, unit-tested) mirror the cron runner: baseline-not-change, 6h alert cooldown, sight-lost after 5 consecutive failures. - Scheduler: a persistent
chrome.alarmsalarm (blincr-local-checks, every 5 min) wakes the service worker, which fetches due pages itself (<all_urls>host permission) and honors each watcher's interval bucket. - CSS extraction: MV3 service workers have no DOM, so an offscreen
document (
offscreen/offscreen.html+dist/offscreen.js,offscreenpermission, reasonDOM_PARSER) answersOFFSCREEN_CSS_EXTRACTrequests withDOMParser+querySelector. Regex and JSON-LD recipes run inline. - Alerts:
chrome.notifications(notificationspermission) plus a per-watcher history (last 20) shown in the popup's Trails tab, where local watchers can be paused, resumed, deleted, or upgraded (☁ button →LOCAL_BLINCR_UPGRADEre-creates the watcher on blincr.com and deletes the local copy only on success — a failed upgrade leaves it untouched). - Limits, by design: checks run only while the browser is open; no webhooks or agent handoff (those are the cloud tier). The choice UI says exactly that — informed choice, no silent upsell.
Popup
Three-tab interface:
- Status — Session controls (start/stop/join), connection indicator, participant list
- Trails — Local trail library from IndexedDB with search, folder management
- About — Platform info, version badge (
__VERSION__,__BUILD_DATE__)
Record agent session (E13.4): a checkbox next to Start explicitly labels
the session as agent browsing. The toolbar badge turns purple ("AI"), the
owner participant is saved with role: "agent", and the finished trail gets
isAgent: true and is auto-filed into the "Agent sessions" folder — it never
appears in the popup's recent-trails list. Labeling is explicit only; no
cadence heuristics decide for the user.
Side Panel
Opens via chrome.sidePanel.open(). Contains the trail replay engine with
play/pause, step forward/back, speed control, skip-to-next-page, and seek.
The library's trail detail view offers Replay, Export (raw .blincr file),
For AI (agent-ready blincr-trail-bundle/1 export — JSON with full events
plus a Markdown digest, built entirely client-side so it works for every
visibility tier; see docs/trail-bundle.md), Upload (E12.3.2 encrypted
backup — works for any trail now, not just ones from a live co-browse
session), Share privately (E12.3.3 — mints a T2 link and copies it,
key fragment included, to the clipboard), and Delete.
The library toolbar's Recovery key button opens keyring management: generate or re-show the device's recovery code, import one from another device, and "Restore backups from cloud" (pulls any T1/T2 backup not already stored locally, decrypting with the imported keyring).
E2EE keyring & backup (E12.3)
crypto.ts— see the entry-points table above. One master secret per device; every trail's content key is derived via HKDF, never stored per-trail.formatRecoveryCode/fromBase32handle the human-facing recovery code;encryptTrail/decryptTrailhandle the AES-GCM envelope ({v, iv, ciphertext}, all base64);exportKeyForFragmentproduces the base64url key for a T2 share link's#k=fragment.background.tsmessage kinds:DB_UPLOAD_TRAIL(rewritten — encrypts and uploads any trail, not just live-session ones),DB_SHARE_PRIVATE,DB_RESTORE_BACKUPS,DB_HAS_KEYRING,DB_GENERATE_RECOVERY_CODE,DB_CURRENT_RECOVERY_CODE,DB_IMPORT_RECOVERY_CODE.- Server API:
docs/backend-api.md(POST /api/user/trails/backup,GET /api/user/trails/:id/backup,POST /api/user/trails/:id/share-private). LocalTrail.cloudTrailId(trail-db.ts) tracks the server-sidecloud_trailsrow id once a trail has been backed up, so share/restore calls can address the right row.
Crawl trails (E17)
A crawl item is a placeable overlay item anchored to a filtered list page. It holds a recipe and an Execute button, and turns that list into a repeatable, resumable extraction that runs entirely in the user's own browser under the user's own session. Nothing reaches blincr's backend: crawl output is T0.
Modules (extension/src/crawl/):
| File | Role |
|---|---|
dom.ts |
Everything that touches the crawled page — row generalisation, relative selectors, extraction, one pagination step |
driver.ts |
The run loop in the service worker: phases, navigation, checkpointing, images, cancellation |
page-agent.ts |
Content-script handler for one CRAWL_PAGE_OP |
politeness.ts |
Per-domain governor enforcing MASTER-PLAN §4.13 |
robots.ts |
robots.txt parse, longest-match-wins, Crawl-delay |
identity.ts |
Row identity across runs; URL normalisation; FNV-1a hashing |
merge.ts |
Re-run reconciliation — add / change / soft-mark gone |
store.ts |
Recipe, run, row and image persistence |
export.ts |
JSON envelope, filename, stored-only ZIP writer |
capabilities.ts |
Optional-permission probe and request |
bridge.ts |
Message routing, trail recording, downloads |
Run phases. paginating → listing → detailing → done. Pagination is
exhausted first, because on an ajax list extracting as you scroll means
extracting a partial list. The loop lives in the driver, not the page: a crawl
tab is usually unfocused, and Chrome throttles hidden-tab timers to roughly
once a minute, so an in-page while (…) await sleep(3000) would stall exactly
when the feature is doing its job. Driver-side looping also routes every cycle
through the politeness governor.
Politeness. MASTER-PLAN §4.6 (≥60s/domain) governs unattended blincr watchers. A crawl is a different actor and has its own budget under §4.13: jittered 2–4s by default, per-recipe configurable, hard floor 1s, one request at a time per domain, effective rate shown to the user. robots.txt can slow a crawl down and never speed it up. robots is honored unless the user marks the origin as one they are authorised on.
Row identity decides both resume and re-run, so the strategy is stored on
every row and shown in the recipe panel. Preference order: element id →
stable data-* attribute → detail URL → user-marked key field → content hash.
Volatile candidates (data-index, data-reactid, bare small integers, UUIDs)
are rejected — they look stable within one run and break every re-run.
Resume. Every row is written to IndexedDB as it completes, never batched. The detail pass skips rows that already carry detail, so resume is the normal path rather than crash-recovery code. On resume the user is asked whether to re-verify the list or trust the stored row set — blincr does not choose.
Re-run never deletes. A row that has vanished is soft-marked with goneAt
and kept; a reappearing row clears the mark. The first-disappeared timestamp is
preserved across later runs.
Output is JSON (crawlSchema: 1). The file is written from a page
context (crawl/download.ts: URL.createObjectURL + <a download>), which
needs no permission — the service worker returns bytes rather than calling
chrome.downloads.download, the one API that would require one. Images are collected as URLs by
default, or downloaded into a content-hash-deduplicated zip with a
manifest.json on the build that carries unlimitedStorage. xlsx is a later
converter over the same envelope.
Build variants
unlimitedStorage cannot be an optional permission — Chrome grants storage
quota at install time and rejects it in optional_permissions, so there is no
runtime grant to offer and an "Enable image storage" button would be a promise
Chrome refuses to keep. Whether a user can keep image files is therefore a
property of the build they installed, and blincr ships two:
| Variant | unlimitedStorage |
Images |
|---|---|---|
pkg/blincr-full — "Blincr Co-Browse" |
yes | URLs and image files |
pkg/blincr-lite — "Blincr Co-Browse Lite" |
no | image URLs only |
cd extension && npm run package # build.js + package-variants.js
The JavaScript is byte-identical in both. Nothing is compiled per variant:
crawl/capabilities.ts reads the live manifest via chrome.runtime.getManifest(),
so a build can never disagree with what the code believes it may do. Root
manifest.json is the canonical full variant and the lite manifest is
derived from it — never the reverse — which keeps local development and the
Playwright suite on the full build. package-variants.js refuses to run if the
root manifest has drifted (no unlimitedStorage in permissions, or it turning
up in optional_permissions), and tests/extension-manifest.test.ts guards the
same invariant in CI-able form.
Editing a recipe (crawl/recipe-diff.ts): the crawl panel's ✎ Edit
reopens the authoring form hydrated from the stored recipe. A cosmetic change
(label, friendly name, delay, budgets, robots flag) saves silently. A
structural one (renamed machineName, a field reading a different element or
attribute, a changed transform, row selector, list URL, pagination, key field
or detail link) invalidates what was collected: the user is told "You need to
run the extraction again after this change" with the exact run and row counts
that will be cleared, and on confirm they are. No aliasing, no migration — an
export whose columns silently disagree with its header is worse than losing a
run the user can repeat.
A run is a trail. The driver emits real BrowseEvents, seeded with the
pre-crawl navigation (the filter-setting walk that produced the list URL) and
closed with the collected rows. A recipient replays how the result set was
reached and sees the rows without re-running anything.
Anchored item persistence (E15.11) and the locator (E18)
Items persist without a session. sendOverlayEvent posts a BROWSE_EVENT,
and background.ts's sendBrowseEvent begins by dropping everything when no
co-browse session is active. Overlay events are therefore written to the
overlays store before that gate (extension/src/overlay-store.ts), and
GET_OVERLAYS answers from the store, merged with the live session's
materialised items (remote participants' overlays arrive over the relay and are
not stored; stored items win on conflict). The store is the record, the relay is
distribution. Overlays are T0 under MASTER-PLAN.md §4.10 — local-first, never
uploaded by this path.
Arrival band (plugins/arrival-band.ts): on load, if the store reports
items for this exact URL, a top band names the count and the authors, with
"Show me" (reveals the rail and jumps to the first item) and "Dismiss"
(remembered per URL for 14 days). It reads the store rather than the live
registry so it can answer before anything has rendered.
Locator rail (plugins/locator.ts + plugins/locator-layout.ts): a fixed
column of marks down the right edge, one per anchored item, ordered by the
anchor's document position. Item types are told apart by ring colour — the
LED never varies, only the ring (watcher red, crawl sky, comment blue, note
copper). Keyboard: roving tabindex, so the rail is one tab stop and
Arrow/Home/End move within it. Reserves a tenth of the viewport top and bottom,
wraps into further columns leftward (capped at 3), highlights the entry
currently in view, greys an entry whose anchor no longer resolves, and scrolls
to the item with a pulse on click. Fed from the overlay manager's live registry,
never from storage — the rail can then never list an item the page is not
actually rendering. Hidden entirely when the page has no items, on
capture-denied pages, and in crawl driver tabs.
Re-anchoring (plugins/anchor-handle.ts): each item carries a ⚓ handle.
Dragging the item nudges its offset and keeps its anchor (E15.7); dragging
the handle outlines the element under the cursor and re-anchors on release,
with Esc to cancel and a drop on empty space detaching to viewport strategy.
The item keeps its on-screen position across a re-anchor — the gesture changes
what it is attached to, not where it sits.
The wrapping arithmetic is a pure function in locator-layout.ts
(tests/locator-layout.test.ts); everything else is covered by
tests-e2e/locator.spec.ts.
Trail Database (IndexedDB)
Database name: blincr-trails, version 4.
Object stores:
trails(keyPath:trailId) — Indexes:createdAt,folderId,title,syncStatus.cloudTrailId(optional) links a synced trail to itscloud_trailsrow.folders(keyPath:folderId) — Indexes:parentId,namecrawlRecipes(keyPath:recipeId) — Indexes:listUrl,updatedAt(v2)crawlRuns(keyPath:runId) — Indexes:recipeId,startedAt(v2)crawlRows(keyPath:[runId, identity]) — Index:runId(v2)crawlImages(keyPath:hash) — deduplicated image blobs (v2)overlays(keyPath:overlayId) — Indexes:url,updatedAt. Anchored items, independent of any session (v3)
Every upgrade step is purely additive and guarded by a contains check, so an
older database gains the new stores and keeps everything already in it.
Operations: CRUD for trails and folders, search by title/domain/tag, split, merge, export (single or full backup), import.
Overlay Plugin System
Extensible annotation layer managed by overlay-manager.ts.
- Plugins register via
registerPlugin()and providerender,update,remove, andcreateInputUImethods. - Overlays are positioned by viewport coordinates or CSS selector + offset.
- Persisted as
overlayevents in the trail.
Built-in plugins:
comment— Persistent page annotations with five styles (default, highlight, question, warning, success).site-comment— Resizable rich-text box for longer commentary (E15.2.2).blincr-marker— Visual anchor for a standing page watcher (E15.3).crawl-item— Recipe + Execute button for a list-page crawl (E17.1.2). The recipe lives in IndexedDB keyed byrecipeId; only a thin summary rides in the overlay data, so a 30-field recipe never replicates through the co-browse relay on every update.
Build
cd extension && node build.js
Uses esbuild. Injects __VERSION__ and __BUILD_DATE__ via define. Output
goes to extension/dist/. Manifest version must be bumped on every source change.
dist/ is committed (2026-08-13). The manifest points at
dist/background.js and dist/content.js, so an ignored dist/ means a fresh
clone cannot load the extension at all — Chrome reports "Could not load
background script", then "Could not load manifest". While blincr is hand-tested
on one machine and moved by push/pull, loading straight from a clone is worth
more than keeping generated files out of git.
The two costs are mitigated rather than accepted silently:
- Stale bundles.
tests/extension-dist.test.tsfails if the committed bundle does not contain the manifest's current version. Since any change underextension/must bump that version, "version present in the bundle" is an exact proxy for "built from this source". Verified by deliberately bumping without rebuilding — it fails with the rebuild command in the message. - Diff noise.
.gitattributesmarksdist/**generated. A conflict there is resolved by rebuilding, never by merging.
Revisit when CI publishes release artifacts.