Skip to content
Open
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
12 changes: 8 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

Binary file added build/notif-icons/outlook.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/notif-icons/slack.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/notif-icons/teams.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 59 additions & 3 deletions core/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* @param {string} [origin] - Optional origin to scope permissions. If omitted, applies to all.
* @returns {{origin?: string, permissions: string[]}} Payload for Browser.grantPermissions.
*/
export function buildClipboardPermissionsModern(origin) {
function buildClipboardPermissionsModern(origin) {
const payload = {
permissions: ["clipboardRead", "clipboardWrite"],
}
Expand All @@ -31,7 +31,7 @@ export function buildClipboardPermissionsModern(origin) {
* @param {string} [origin]
* @returns {{origin?: string, permissions: string[]}}
*/
export function buildClipboardPermissionsLegacy(origin) {
function buildClipboardPermissionsLegacy(origin) {
const payload = {
permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"],
}
Expand All @@ -51,7 +51,56 @@ export function buildClipboardPermissionsLegacy(origin) {
* @returns {string} .route - Either 'insertText' (plain) or 'preseed' (rich).
* @returns {string} .reason - Human-readable explanation.
*/
export function selectPasteRoute(focusDescriptor) {
/**
* Minimal extension → MIME map for clipboard file paste. Covers the file kinds a
* user is likely to copy-paste into a remote page (images + video + a few docs);
* anything unknown falls back to application/octet-stream so the remote `File`
* still carries bytes + a name (the target site sniffs content / extension).
*
* The map is the source of truth for paste mime — `clipboard.readImage()` only
* yields a thumbnail icon for a copied *file*, so the file path is read directly
* and its type derived from the name here.
*
* @param {string} name - File name or path.
* @returns {string} MIME type.
*/
function mimeForName(name) {
const ext = String(name || "")
.toLowerCase()
.split(".")
.pop()
const map = {
// images
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
svg: "image/svg+xml",
heic: "image/heic",
avif: "image/avif",
// video
mp4: "video/mp4",
m4v: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
// audio
mp3: "audio/mpeg",
wav: "audio/wav",
m4a: "audio/mp4",
ogg: "audio/ogg",
// docs
pdf: "application/pdf",
txt: "text/plain",
zip: "application/zip",
}
return (ext && map[ext]) || "application/octet-stream"
}

function selectPasteRoute(focusDescriptor) {
const { isContentEditable = false, isRichEditor = false } = focusDescriptor || {}

if (isContentEditable || isRichEditor) {
Expand All @@ -67,3 +116,10 @@ export function selectPasteRoute(focusDescriptor) {
reason: "Plain input; use Input.insertText for direct text insertion",
}
}

module.exports = {
buildClipboardPermissionsModern,
buildClipboardPermissionsLegacy,
mimeForName,
selectPasteRoute,
}
21 changes: 21 additions & 0 deletions core/clipboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,30 @@ import { describe, expect, it } from "vitest"
import {
buildClipboardPermissionsLegacy,
buildClipboardPermissionsModern,
mimeForName,
selectPasteRoute,
} from "./clipboard.js"

describe("mimeForName", () => {
it("maps common video extensions", () => {
expect(mimeForName("clip.mp4")).toBe("video/mp4")
expect(mimeForName("movie.MOV")).toBe("video/quicktime")
expect(mimeForName("a.webm")).toBe("video/webm")
})

it("maps common image extensions", () => {
expect(mimeForName("pic.png")).toBe("image/png")
expect(mimeForName("photo.JPG")).toBe("image/jpeg")
expect(mimeForName("anim.gif")).toBe("image/gif")
})

it("falls back to octet-stream for unknown or missing extensions", () => {
expect(mimeForName("noext")).toBe("application/octet-stream")
expect(mimeForName("archive.zzz")).toBe("application/octet-stream")
expect(mimeForName("")).toBe("application/octet-stream")
})
})

describe("clipboard permissions", () => {
describe("buildClipboardPermissionsModern", () => {
it("returns modern permission names without origin", () => {
Expand Down
86 changes: 77 additions & 9 deletions core/notifications-sidechain.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,20 @@ const ADAPTERS = [
name: "slack",
script: "slack-notify.js",
match: (h) => /(^|\.)slack\.com$/.test(h),
iconUrl: "https://a.slack-edge.com/80588/marketing/img/icons/favicon-32-electron.png",
// Renderer-bell icon only (the OS banner + dock use bundled build/notif-icons/slack.png).
// The old favicon-32-electron.png path 404/403'd; app-256.png is a stable Slack logo.
iconUrl: "https://a.slack-edge.com/80588/img/icons/app-256.png",
// Slack runs every workspace under one origin (app.slack.com), so the default
// per-origin grouping would merge all workspaces into one badge. Derive the group
// key from the Tab's URL team id instead — one Tab per workspace, so the URL is the
// authoritative workspace identity (more durable than the in-page capture script).
groupKey: slackGroupKey,
// Slack delivers many notifications from its service worker's `push` handler
// (`registration.showNotification`), a realm the page hook can't reach. `swScript`
// is injected into the matching service_worker target to patch showNotification there
// and ship the same `__cdpNotify` toasts (t067). The SW URL carries no team id, so the
// script derives the per-workspace groupKey from the notification payload instead.
swScript: "slack-sw-notify.js",
},
]

Expand All @@ -67,6 +75,15 @@ function createNotificationCenter(deps) {
if (!sourceCache.has(adapter.name)) sourceCache.set(adapter.name, readInject(adapter.script))
return sourceCache.get(adapter.name)
}
// Service-worker capture script, memoized per adapter (separate cache key so it never
// collides with the page `script`). Only adapters that declare `swScript` have one.
const swSourceCache = new Map()
const swSourceFor = (adapter) => {
if (!swSourceCache.has(adapter.name)) {
swSourceCache.set(adapter.name, readInject(adapter.swScript))
}
return swSourceCache.get(adapter.name)
}

const seeded = load()
let notifications = Array.isArray(seeded) ? seeded : []
Expand Down Expand Up @@ -102,7 +119,7 @@ function createNotificationCenter(deps) {
// back-compatible display.
activate: n.activate || null,
targetEntity: n.targetEntity || null,
icon: (adapter || {}).iconUrl || null,
icon: adapter?.iconUrl || null,
ts: n.ts || (deps.now ? deps.now() : Date.now()),
},
cap,
Expand All @@ -117,18 +134,62 @@ function createNotificationCenter(deps) {
const adapter = adapterFor(target.url)
if (!adapter || !target.webSocketDebuggerUrl) return
const ws = new WebSocketCtor(target.webSocketDebuggerUrl)
sideChannels.set(target.id, ws)
let cmdId = 1
let opened = false
const cdp = (method, params) =>
ws.send(JSON.stringify({ id: cmdId++, method, params: params || {} }))
// Keep the remote Tab's page alive so its capture script keeps firing even when the
// Tab is backgrounded on the remote browser. Chromium freezes idle background tabs
// (~5 min), which pauses the page JS that calls `new Notification()` — so background
// Tabs silently stop delivering toasts (the asymmetry where only the active Tab
// notified). Forcing the web lifecycle to "active" prevents the freeze WITHOUT making
// the page "visible" (visibility is orthogonal in the CDP spec — verified against
// Page.setWebLifecycleState, which only takes "frozen"|"active"), so Slack still treats
// the Tab as hidden and keeps firing desktop notifications for the side-channel to
// capture. Re-applied every reconcile because the browser can re-freeze. See t066.
const keepAlive = () => {
if (opened) cdp("Page.setWebLifecycleState", { state: "active" })
}
sideChannels.set(target.id, { ws, keepAlive })
ws.on("open", () => {
opened = true
cdp("Runtime.enable")
cdp("Page.enable")
cdp("Runtime.addBinding", { name: NOTIFY_BINDING })
// document-start for future loads + the already-loaded document.
cdp("Page.addScriptToEvaluateOnNewDocument", { source: sourceFor(adapter) })
cdp("Runtime.evaluate", { expression: sourceFor(adapter) })
keepAlive()
})
wireToastAndDrop(ws, target)
}

// Service-worker side-channel (t067). Slack/Teams/Outlook deliver many notifications from
// their service worker's `push` handler via `registration.showNotification` — a realm the
// page hook (`window.Notification`) can't reach. A service_worker target supports Runtime
// (not Page), so we patch via a one-shot Runtime.evaluate into the running worker rather
// than Page.addScriptToEvaluateOnNewDocument. Best-effort: a worker that spins up fresh on
// a push and fires before the next 5s reconcile attaches is missed (no SW-start barrier
// here). The page keep-alive (t066) keeps the registration warm, which keeps the worker
// listed in /json across reconciles. No keep-alive on the worker itself (no web lifecycle).
function attachServiceWorker(target) {
const adapter = adapterFor(target.url)
if (!adapter?.swScript || !target.webSocketDebuggerUrl) return
const ws = new WebSocketCtor(target.webSocketDebuggerUrl)
let cmdId = 1
const cdp = (method, params) =>
ws.send(JSON.stringify({ id: cmdId++, method, params: params || {} }))
sideChannels.set(target.id, { ws, keepAlive: () => {} })
ws.on("open", () => {
cdp("Runtime.enable")
cdp("Runtime.addBinding", { name: NOTIFY_BINDING })
cdp("Runtime.evaluate", { expression: swSourceFor(adapter) })
})
wireToastAndDrop(ws, target)
}

// Shared toast ingest + self-removal wiring for both the page and service-worker channels.
function wireToastAndDrop(ws, target) {
ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString())
Expand All @@ -138,7 +199,8 @@ function createNotificationCenter(deps) {
} catch {}
})
const drop = () => {
if (sideChannels.get(target.id) === ws) sideChannels.delete(target.id)
const cur = sideChannels.get(target.id)
if (cur && cur.ws === ws) sideChannels.delete(target.id)
}
ws.on("close", drop)
ws.on("error", drop)
Expand All @@ -156,17 +218,23 @@ function createNotificationCenter(deps) {
}
}
if (!Array.isArray(list)) return
const matched = list.filter((t) => t.type === "page" && adapterFor(t.url))
const liveIds = new Set(matched.map((t) => t.id))
for (const [id, ws] of sideChannels) {
const pages = list.filter((t) => t.type === "page" && adapterFor(t.url))
// Service-worker targets whose adapter declares a swScript (t067).
const workers = list.filter((t) => t.type === "service_worker" && adapterFor(t.url)?.swScript)
const liveIds = new Set([...pages, ...workers].map((t) => t.id))
for (const [id, { ws }] of sideChannels) {
if (!liveIds.has(id)) {
try {
ws.close()
} catch {}
sideChannels.delete(id)
}
}
for (const t of matched) if (!sideChannels.has(t.id)) attach(t)
for (const t of pages) if (!sideChannels.has(t.id)) attach(t)
for (const t of workers) if (!sideChannels.has(t.id)) attachServiceWorker(t)
// Re-apply keep-alive to every live side-channel each cycle — the browser may have
// re-frozen a backgrounded Tab since the last pass (t066). SW channels no-op.
for (const [, ch] of sideChannels) ch.keepAlive()
}

return {
Expand Down Expand Up @@ -195,7 +263,7 @@ function createNotificationCenter(deps) {
},
unreadCount: () => unreadCount(notifications),
close: () => {
for (const [, ws] of sideChannels) {
for (const [, { ws }] of sideChannels) {
try {
ws.close()
} catch {}
Expand Down
109 changes: 109 additions & 0 deletions core/notifications-sidechain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,36 @@ describe("reconcile — idempotent / drop", () => {
})
})

describe("keep-alive (t066) — prevent background-tab freeze", () => {
it("forces the page web lifecycle to active on open so a backgrounded Tab keeps firing notifications", async () => {
const { center } = makeCenter()
await center.reconcile([teamsTarget()])
const ws = FakeWs.instances[0]
ws.open()
const keepAlive = ws.sent.filter((m) => m.method === "Page.setWebLifecycleState")
expect(keepAlive).toHaveLength(1)
expect(keepAlive[0].params.state).toBe("active")
})

it("does not send keep-alive before the socket opens", async () => {
const { center } = makeCenter()
await center.reconcile([teamsTarget()])
const ws = FakeWs.instances[0]
const count = ws.sent.filter((m) => m.method === "Page.setWebLifecycleState").length
expect(count).toBe(0)
})

it("re-applies keep-alive on each reconcile (browser may re-freeze the Tab)", async () => {
const { center } = makeCenter()
await center.reconcile([teamsTarget()])
const ws = FakeWs.instances[0]
ws.open()
await center.reconcile([teamsTarget()]) // same target, socket already open
const count = ws.sent.filter((m) => m.method === "Page.setWebLifecycleState").length
expect(count).toBe(2)
})
})

describe("ingest dedup", () => {
it("drops a duplicate toast within the dedup window — one stored entry, one onEntry", async () => {
const { center, onEntry } = makeCenter()
Expand Down Expand Up @@ -308,6 +338,85 @@ describe("slack adapter — per-workspace grouping (t064)", () => {
})
})

describe("service-worker capture (t067)", () => {
const slackSwTarget = (id = "sw1", over = {}) => ({
id,
type: "service_worker" as const,
url: "https://app.slack.com/service-worker.js",
webSocketDebuggerUrl: `ws://host/devtools/worker/${id}`,
...over,
})

it("attaches to a service_worker target whose adapter declares a swScript and injects it via Runtime.evaluate (no Page domain)", async () => {
const { center } = makeCenter()
await center.reconcile([slackSwTarget()])
expect(FakeWs.instances).toHaveLength(1)
const ws = FakeWs.instances[0]
ws.open()
const methods = ws.sent.map((m) => m.method)
expect(methods).toContain("Runtime.enable")
expect(methods).toContain("Runtime.addBinding")
expect(methods).toContain("Runtime.evaluate")
expect(methods).not.toContain("Page.enable")
expect(methods).not.toContain("Page.addScriptToEvaluateOnNewDocument")
const evalCmd = ws.sent.find((m) => m.method === "Runtime.evaluate")
expect(evalCmd.params.expression).toContain("slack-sw-notify.js")
})

it("does not attach to a service_worker whose adapter has no swScript (Teams)", async () => {
const { center } = makeCenter()
await center.reconcile([
{
id: "tsw",
type: "service_worker",
url: "https://teams.microsoft.com/sw.js",
webSocketDebuggerUrl: "ws://host/devtools/worker/tsw",
},
])
expect(FakeWs.instances).toHaveLength(0)
})

it("ingests a toast from the SW channel, stamped with the slack adapter + payload groupKey", async () => {
const { center } = makeCenter()
await center.reconcile([slackSwTarget()])
const ws = FakeWs.instances[0]
ws.open()
// SW URL has no team id, so the worker script supplies the per-workspace groupKey.
ws.notify({ id: "swn", title: "@bob: ping", groupKey: "slack:T999" })
expect(center.list()[0].adapter).toBe("slack")
expect(center.list()[0].groupKey).toBe("slack:T999")
})

it("never sends keep-alive on a SW channel (no web lifecycle on a worker)", async () => {
const { center } = makeCenter()
await center.reconcile([slackSwTarget()])
const ws = FakeWs.instances[0]
ws.open()
await center.reconcile([slackSwTarget()]) // triggers the keep-alive re-apply pass
expect(ws.sent.filter((m) => m.method === "Page.setWebLifecycleState")).toHaveLength(0)
})

it("drops the SW channel when the worker vanishes", async () => {
const { center } = makeCenter()
await center.reconcile([slackSwTarget()])
const ws = FakeWs.instances[0]
await center.reconcile([])
expect(ws.closed).toBe(true)
})

it("attaches both the page and the SW channel for the same workspace", async () => {
const { center } = makeCenter()
const slackPage = {
id: "p1",
type: "page" as const,
url: "https://app.slack.com/client/T1/C1",
webSocketDebuggerUrl: "ws://host/devtools/page/p1",
}
await center.reconcile([slackPage, slackSwTarget()])
expect(FakeWs.instances).toHaveLength(2)
})
})

describe("store mutations + persistence", () => {
async function seeded() {
const ctx = makeCenter()
Expand Down
Loading