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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ release
# Web build runtime state (default paths write to repo root; override via env)
web-settings.json
web-notifications.json
web-history.json

# Baked build identity (Docker builder writes it; never committed)
.gitsha
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ A lightweight Electron app that connects to a remote Chromium-based browser via
- **Adaptive Viewport**: An optional mode that eliminates letterbox bars by resizing the remote page to match the canvas via `Emulation.setDeviceMetricsOverride`. The main process caches the last override and re-applies it before `Page.startScreencast` on every (re)connect. See `docs/adr/0002-adaptive-viewport.md`.
- **Packaging allowlist**: `build.files` in `package.json` is an explicit allowlist, not a denylist. Any new file `main.js` requires/reads at runtime (e.g. anything under `core/` or `inject/`) must be added there, or it gets stripped from the asar and the packaged app throws `Cannot find module` on launch. `core/**/*.js` is already in the allowlist; test files (`*.test.ts`) are excluded. Renderer code is safe — it's bundled into `dist/` by Vite.
- **Settings persistence**: Host, port, theme, pins, sidebar width, sidebar-collapsed state, pinned-open state, `adaptiveViewport`, `forceOnClient`, `switchEffect`, `notificationsEnabled`, and `syncTheme` are stored in `userData/settings.json`. Saving a new CDP address immediately reconnects to the first available tab. Legacy `switchBlur` boolean is migrated to `switchEffect`, and legacy `bookmarks` to `pins`, on first load. The three web-only client prefs — `qualityTier`, `inputTransport`, `latencyHud` — persist **per device** in server ui-state under `<base>_<deviceId>` (t100, `src/lib/device-prefs.ts`, `core/settings-store.js` `DEVICE_KEY_PREFIXES`), so they survive an iPad-PWA localStorage wipe; `qualityTier` also mirrors into a plain global shadow the screencast connector reads (the connecting device's tier applies to the shared stream).
- **Cross-device sync (t103, ADR-0017)**: The web server is the source of truth for **pins + browsing history**, shared across every web/PWA device that talks to it. Electron opts in via two settings — `syncEnabled` + `syncServerUrl` (a reachable **plaintext tailnet** web build, no auth/E2E; Electron-only Settings "Sync" card): when set, `main.js`'s pin IPC handlers + history proxy CRUD to `${syncServerUrl}/api/pins*` / `/api/history` instead of the local `settings.json` / `userData/history.json`; a network failure falls back to local (never a crash). First enable is **server-wins** (`app.tsx` re-loads pins from the server). Browsing history is captured with **no CDP History domain** — `visitsFromTabs` (`core/history-store.js`) diffs the existing `/json` tab poll (`type === "page"` only) into `{url,title,ts}` visits; the server records in `reconcileCycle`, Electron in the `cdp:list-tabs` handler. Suggestions ride `window.cdp.getHistory()` into the New Tab omnibox (`src/lib/tab-suggest.ts`). Web is inherently synced, so its Sync card is hidden. **Pins update near-real-time**: `app.tsx` re-fetches the pin store on the ~3s tab-poll cadence, re-resolves links, and applies only on an actual change (a grace window after a local edit prevents a stale read reverting an in-flight write), so a pin made on another device appears within seconds without reload. History is fresh on every omnibox open.
- **Pins (live-tab holders)**: A pin holds a remote tab (`targetId`), hidden from the Tabs list while linked. Click activates the linked tab or opens+links a fresh one; cmd/middle-click opens an unlinked throwaway tab. Created from a live tab only (toolbar star, right-click tab → Pin, or drag a tab into the Pinned section). A linked pin mirrors its tab's live title/favicon (restoring the saved title when the tab closes); the active pin shows an Arc-style URL-drift cue (a `/` separator and a favicon "Back to Pinned URL" button) when its tab navigates off the saved URL. Closing a pin's tab reverts it to unlinked; un-pinning (confirm dialog) returns the tab to the Tabs list. Cmd+1..9 indexes all pins then visible tabs; Ctrl+Tab cycles open pins + tabs. Link resolution is pure (`src/lib/pins.ts`); persistence/effects live in main + `app.tsx`. See `docs/adr/0004-pin-live-tab-model.md`.
- **Unread badges by group**: Sidebar unread counts are computed by `aggregateUnread` (`src/lib/unread-aggregator.ts`) and keyed by `groupKey` (from the notification entry) falling back to `groupKeyForUrl(url)` — Slack's per-workspace `slack:{teamId}`, else URL origin. Every tab/pin of the same app shares one count whether or not it captured the notification, and a dormant pin still badges by resolving its saved URL through the same key derivation.
- **Local tabs**: Real local web pages rendered as in-DOM Electron `<webview>`s on a shared `persist:local` session (`src/components/local-webviews.tsx`) — full device access (OS notifications, speaker/mic, camera, screen-share) that CDP screencast tabs can't have. Because a `<webview>` is an in-page OOPIF, React overlays (dialogs, menus, tooltips, the settings sheet) stack **above the live page via CSS z-index** — no native z-order, no freeze. `activeKind: 'cdp' | 'local'` chooses the surface and routes the toolbar/nav hotkeys (`RemotePage` vs the active webview's methods). The renderer holds `LocalTab` metadata and maps webview DOM events to it; only the active webview is shown (others `display:none`, kept alive in the background). All open local tabs persist + restore on launch; pinned ones (a `pinned` flag, distinct from CDP PINNED pins) sort atop the LOCAL TABS section. Unpacked MV3 extensions load into the local session only (`localExtensionPaths`) and their content scripts inject into webview guests; the toolbar shows a Chrome-like action icon per extension (opens its popup in a popover), and popup/options also open as a local tab via the `chrome-extension://` URL. Permissions auto-granted behind the `autoGrantLocalMedia` setting (a `media` request triggers `askForMediaAccess`); packaging ships mic/cam/audio-capture Info.plist keys + entitlements (`build/entitlements.mac.plist`, hardened runtime). See `docs/adr/0005-local-tabs-base-window.md`.
Expand Down Expand Up @@ -91,6 +92,7 @@ cdp-browser/
│ ├── notif-mutes.js # Pure mute logic: muteKey(entry) (slack→groupKey, else adapter) + isMuted + unreadExcluding(list,mutes,masterOn) (unread, 0 when master off). Mirrored in src/lib/notif-mutes.ts; gates per-device push in web/server.mjs (t093) AND the Electron OS-notify (shouldNotifyOs) + dock badge via a global notifMutes (t101)
│ ├── push-subscriptions.js # Pure E0: endpoint-reconciled deviceId (t095, ADR-0014). reconcileDeviceId(existingSubs, {endpoint}) → {deviceId, isNew} — server-authoritative identity surviving storage wipe
│ ├── push-send-options.js # Pure E2: push notification send options (t095). pushSendOptions() → {urgency:"high", TTL:1800} for timely, non-stale delivery
│ ├── history-store.js # Pure browsing-history read-model (t103, ADR-0017): recordVisit (dedup-by-url, frecency, cap), rankHistory (New Tab omnibox source), visitsFromTabs (tab-poll → visits diff). Shared by web/server.mjs + main.js; matcher mirrored in src/lib/tab-suggest.ts
│ (Channel Exclude logic lives in src/lib/slack-excludes.ts — renderer edits ui-state, server sweep reads it, t072)
│ └── crypto-envelope.js # Server-side AES-256-GCM seal/open (E2E mode); mirrors src/lib/crypto-envelope.ts
├── web/
Expand Down Expand Up @@ -140,6 +142,8 @@ cdp-browser/
│ ├── key-routing.ts # isOsReservedKey — gates Input Forwarding for macOS-reserved combos
│ ├── typing-surface.ts # isTypingSurface(activeKind) — bare-char shortcut guard: true when canvas/webview is active (t065)
│ ├── pins.ts # Pin link resolution: resolvePinLink, pinForTarget, dropDeadLinks
│ ├── tab-suggest.ts # Pure New Tab omnibox matcher (t103): merges history + open tabs → ranked switch/history suggestions, diacritic-safe. Mirrors core/history-store.js ranking
│ ├── fold-text.ts # Pure Vietnamese-aware diacritic/case fold for substring matching (t103); mirrors core/history-store.js fold
│ ├── local-tabs.ts # Local tabs: LocalTab shape, sortPinnedFirst, to/fromPersisted
│ ├── closed-tabs.ts # Unified close-ordered reopen stack ({kind,url}) across CDP + local
│ ├── active-order.ts # MRU activation history across CDP + local: touchActive, dropActive, mostRecent
Expand Down Expand Up @@ -187,7 +191,7 @@ cdp-browser/
├── status-bar.tsx # Bottom status bar for loading/error states (replaces mid-viewport overlay)
├── settings-dialog.tsx # Non-modal right Sheet drawer (showOverlay=false); grouped cards; hybrid mouse-leave + keyboard-commit close (mouse-leave gated to fine pointer via shouldArmLeaveTimer; coarse pointer dismisses via header close button + scrim tap); Cmd+, toggles
├── notification-bell.tsx # Bell icon + badge + popover; grouped by conversation
├── new-tab-dialog.tsx # URL input + pin quick-launch (reused for CDP and local)
├── new-tab-dialog.tsx # Omnibox: URL input + pin quick-launch + history suggestions & "Switch to tab" for open tabs (t103, via tab-suggest.ts + window.cdp.getHistory)
├── edit-pin-dialog.tsx # Edit pin title/URL, with "Use current tab URL" when the linked tab has drifted
├── local-webviews.tsx # Local tabs as in-DOM <webview>s; imperative nav API + DOM-event → LocalTab mapping
├── install-banner.tsx # iOS Safari install nudge (Add to Home Screen); 1-week localStorage dismiss; PWA-only
Expand Down
9 changes: 9 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ _Avoid_: preview, mini-client, mobile Slack.
A locally-rendered web page displayed as an in-DOM Electron `<webview>` element inside the chrome view. Unlike a **Tab** (which renders a remote page as a JPEG screencast), a Local Tab has full device access: real OS notifications, audio, mic, camera, screen-share, and loadable MV3 extensions. The renderer holds `LocalTab` metadata (`{ id, url, title, favicon?, pinned, loading, canGoBack, canGoForward, audible, muted }`); the main process owns the `persist:local` session and extension loading. Local Tabs occupy the LOCAL TABS sidebar section; a `pinned` flag (distinct from CDP Pins) keeps them atop that section and restores them on relaunch. See `docs/adr/0005-local-tabs-base-window.md`.
_Avoid_: native tab, page view, webview tab.

**Visit**:
One recorded page load, `{ url, title, visitCount, lastVisit }`, folded into the browsing-history store by `core/history-store.js`'s `recordVisit` (dedup by url) from a `/json` tab-poll diff (`visitsFromTabs`) — CDP/Edge exposes no History domain, so a Visit is only ever inferred from a Tab's url changing, not read back from the Remote Browser. Ranked by frecency (`rankHistory`) for the New Tab omnibox. See `docs/adr/0017-shared-sync-backend-for-pins-and-history.md`.
_Avoid_: history entry, page visit, navigation event.

**Sync Backend**:
The web server acting as the shared source of truth for **Pin**s and **Visit**s across a user's devices (ADR-0017). Web is a Sync Backend client by construction (`/api/pins*`, `/api/history*`); Electron opts in per device via `syncEnabled` + `syncServerUrl` (plaintext tailnet URL, no auth/E2E), proxying its Pin + Visit reads/writes there instead of its local `settings.json` / `history.json` and falling back to local on any network failure. First enable is server-wins.
_Avoid_: sync server, sync service, cloud sync.

## Relationships

- A **Remote Browser** hosts many **Tabs**; exactly one is the **Active Tab**.
Expand All @@ -110,6 +118,7 @@ _Avoid_: native tab, page view, webview tab.
- A **Notification Side-Channel** attaches to a background **Tab**'s target and uses a **Notification Adapter** to run **Notification Capture** — independent of the **Active Tab**'s screencast socket. Clicking the result activates the owning Tab and, if the entry carries an `activate` intent, the activation registry maps it to a **Remote Page** deep-open intention.
- For Slack, the **Slack Content Sweep** is the authoritative **Notification Capture** writer; the in-page hijack provides only an instant foreground toast. The sweep reads creds from a live **Tab**, persists workspaces in the **Workspace Registry**, uses the **Parked Tab** keeper (pin-deferred since t098 — a pinned workspace is owned by its Pin), and respects the **Channel Exclude** list.
- A **Local Tab** renders a real local web page (in-DOM `<webview>`) alongside CDP Tabs — it does not use **Screencast Frames** or **Input Forwarding**; it gets direct device access instead.
- The **Sync Backend** is the shared store for **Pin**s and **Visit**s; the New Tab omnibox ranks **Visit**s (`rankHistory`/`suggest`) alongside currently-open Tabs to offer a "switch to tab" result ahead of opening a duplicate.

## Example dialogue

Expand Down
99 changes: 99 additions & 0 deletions core/history-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Pure browsing-history read-model (t103, ADR-0017). Shared by web/server.mjs and
// main.js — neither can import the renderer's ESM, so the matcher is mirrored in
// src/lib/tab-suggest.ts (same duplication pattern as core/notif-mutes.js).
//
// CDP/Edge exposes no History domain, so history is *recorded* from the tab poll:
// visitsFromTabs diffs a /json snapshot into visits, the caller stamps `ts` and
// folds them in with recordVisit. rankHistory serves the New Tab omnibox.
//
// A Visit is { url, title, visitCount, lastVisit }. Ranking is frecency
// (frequency × recency) so a page you open often + recently floats to the top.

const DAY = 24 * 3600_000
const DEFAULT_CAP = 1000

// Diacritic + case fold for Vietnamese-aware matching. NFD strips tone/diacritic
// combining marks; đ/Đ don't decompose so they're replaced explicitly.
function fold(s) {
return (s || "")
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.toLowerCase()
}

// Only real web pages get remembered — no blank/internal/non-http schemes.
function isHistoryableUrl(url) {
return typeof url === "string" && /^https?:\/\//i.test(url)
}

// Frecency: visits weighted by recency. A fresh page (age 0) keeps full weight; an
// old one decays toward zero. Simple 1/(1+ageDays) decay — enough to order suggestions.
function frecencyScore(visit, now) {
const ageDays = Math.max(0, now - visit.lastVisit) / DAY
return visit.visitCount * (1 / (1 + ageDays))
}

// Fold a visit into the store: dedup by url (bump count + recency, keep newest
// non-empty title), else prepend. Caps to the lowest-frecency entries. Immutable.
function recordVisit(visits, { url, title, ts }, opts = {}) {
const cap = opts.cap || DEFAULT_CAP
const now = opts.now != null ? opts.now : ts
const idx = visits.findIndex((v) => v.url === url)
let next
if (idx >= 0) {
const existing = visits[idx]
const updated = {
url,
title: title || existing.title,
visitCount: existing.visitCount + 1,
lastVisit: ts,
}
next = visits.slice()
next[idx] = updated
} else {
next = [{ url, title: title || url, visitCount: 1, lastVisit: ts }, ...visits]
}
if (next.length > cap) {
next = next
.slice()
.sort((a, b) => frecencyScore(b, now) - frecencyScore(a, now))
.slice(0, cap)
}
return next
}

// The New Tab omnibox source: filter by folded query over title + url, order by
// frecency, cap to `limit`. Empty query returns the top-frecency pages.
function rankHistory(visits, { query, now, limit }) {
const q = fold(query)
const matched = q
? visits.filter((v) => fold(v.title).includes(q) || fold(v.url).includes(q))
: visits.slice()
matched.sort((a, b) => frecencyScore(b, now) - frecencyScore(a, now))
return matched.slice(0, limit)
}

// Diff a /json tab snapshot against the last-seen url per tab. A tab whose url is
// new (or changed) yields one visit; unchanged and non-historyable tabs are skipped.
// Returns the changed {url,title} pairs (caller stamps ts) + the next per-tab map.
function visitsFromTabs(prevByTab, tabs) {
const changed = []
const next = {}
for (const t of tabs) {
if (!isHistoryableUrl(t.url)) continue
next[t.id] = t.url
if (prevByTab[t.id] !== t.url) changed.push({ url: t.url, title: t.title || "" })
}
return { changed, next }
}

module.exports = {
fold,
isHistoryableUrl,
frecencyScore,
recordVisit,
rankHistory,
visitsFromTabs,
}
Loading