From 64edf55c1367d000ec0b55a6caaa05f9f004a0b8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 16:11:24 -0800 Subject: [PATCH 1/3] feat(extension): improve page capture, attachments, and browser shortcuts --- .../content/docs/browser-extension/index.mdx | 36 ++ apps/docs/content/docs/meta.json | 2 + apps/docs/content/docs/search/sim-links.mdx | 18 +- apps/extension/DESIGN.md | 4 +- apps/extension/README.md | 8 +- apps/extension/package.json | 2 +- apps/extension/scripts/build.ts | 9 +- apps/extension/scripts/test-e2e.ts | 370 +++++++++++- apps/extension/src/background.ts | 68 ++- apps/extension/src/capture.ts | 114 +++- apps/extension/src/config.ts | 2 + apps/extension/src/extract.ts | 31 +- apps/extension/src/intent.ts | 3 +- apps/extension/src/omnibox.ts | 30 +- apps/extension/src/reference.ts | 185 ++++++ apps/extension/src/shortcut-hint.tsx | 48 ++ apps/extension/src/sidepanel.tsx | 332 +++++++---- apps/extension/src/site-access.tsx | 234 ++++++++ apps/extension/src/site-permissions.ts | 48 ++ .../app/(landing)/privacy/privacy-content.tsx | 8 +- .../[id]/sim-links/bookmarks/route.ts | 20 + apps/sim/app/extension/[workspaceId]/chat.tsx | 28 +- .../app/extension/create-link/destination.ts | 44 ++ .../app/extension/create-link/link-draft.tsx | 33 ++ apps/sim/app/extension/create-link/page.tsx | 28 + apps/sim/app/extension/hooks/use-composer.tsx | 109 +++- .../app/extension/hooks/use-page-context.ts | 128 +++- .../o/[organizationId]/organization.tsx | 7 +- apps/sim/app/extension/providers.tsx | 8 +- apps/sim/app/extension/sign-in.tsx | 38 ++ .../components/organization-conversation.tsx | 41 +- .../home/components/composer/composer.tsx | 31 +- .../links/browser-shortcuts-modal.tsx | 68 +++ .../app/o/[organizationId]/links/columns.ts | 3 +- .../app/o/[organizationId]/links/loading.tsx | 21 +- .../links/managed-bookmarks.tsx | 107 ++++ .../[organizationId]/links/sim-link-modal.tsx | 10 +- .../o/[organizationId]/links/sim-links.tsx | 31 +- .../[organizationId]/[name]/page.tsx | 50 ++ .../mothership-chat/mothership-chat.tsx | 9 +- .../attached-files-list.tsx | 4 +- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 27 +- .../home/components/user-input/user-input.tsx | 30 +- .../user-input/hooks/use-file-attachments.ts | 55 +- .../user-input/hooks/use-file-drop.ts | 115 ++++ apps/sim/hooks/queries/sim-links.ts | 9 + apps/sim/hooks/use-chat-input-focus.ts | 12 +- apps/sim/hooks/use-composer-submission.ts | 84 +++ apps/sim/lib/api/contracts/sim-links.ts | 18 +- .../lib/mothership/generated/docs-manifest.ts | 1 + apps/sim/lib/navigation/paths.ts | 2 + .../lib/sim-links/application/operations.ts | 7 + .../lib/sim-links/application/use-cases.ts | 23 + apps/sim/lib/sim-links/repository.ts | 15 +- apps/sim/lib/sim-links/validation.ts | 1 + .../browser-extension-e2e/chat-file-drop.ts | 36 ++ .../sim/scripts/test-browser-extension-e2e.ts | 546 +++++++++++++++++- apps/sim/scripts/test-sim-links-e2e.ts | 46 ++ bun.lock | 2 +- .../browser-extension-protocol/src/index.ts | 52 ++ packages/emcn/src/components/index.ts | 1 + 61 files changed, 3073 insertions(+), 379 deletions(-) create mode 100644 apps/docs/content/docs/browser-extension/index.mdx create mode 100644 apps/extension/src/reference.ts create mode 100644 apps/extension/src/shortcut-hint.tsx create mode 100644 apps/extension/src/site-access.tsx create mode 100644 apps/extension/src/site-permissions.ts create mode 100644 apps/sim/app/api/organizations/[id]/sim-links/bookmarks/route.ts create mode 100644 apps/sim/app/extension/create-link/destination.ts create mode 100644 apps/sim/app/extension/create-link/link-draft.tsx create mode 100644 apps/sim/app/extension/create-link/page.tsx create mode 100644 apps/sim/app/o/[organizationId]/links/browser-shortcuts-modal.tsx create mode 100644 apps/sim/app/o/[organizationId]/links/managed-bookmarks.tsx create mode 100644 apps/sim/app/sim-links/[organizationId]/[name]/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-drop.ts create mode 100644 apps/sim/hooks/use-composer-submission.ts create mode 100644 apps/sim/scripts/browser-extension-e2e/chat-file-drop.ts diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx new file mode 100644 index 00000000000..61843342157 --- /dev/null +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -0,0 +1,36 @@ +--- +title: Sim browser extension +description: Ask Sim about the page you’re reading, share a screenshot, and open your team’s Sim Links from Chrome. +--- + +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) to ask questions about a page without leaving it. Open Chrome’s **Extensions** menu and pin **Sim** to keep it one click away. + +## Ask about a page + +1. Open a web page and click **Sim** in the Chrome toolbar. Sign in to Sim if prompted. +2. In the side panel, select the attachment chip to review the captured text, or use its **×** to remove it. To attach the page again after it changes, use the globe next to the message field. +3. Ask a question and send it. For example, on a release notes page, ask “Which changes affect my team?” + +To ask about a passage, select the text on the page, right-click, and choose **Ask Sim about selection**. You can attach up to three snapshots to compare pages. A snapshot stays as it was when you captured it. Chrome settings pages, other extensions, and pages without readable text can’t be attached. + +In version 0.1.3 or later, open **+ → Reference current page on send** to include the active tab each time you send a message. This is off by default. The setting appears above the workspace list, and Sim remembers your choice in this Chrome profile. The globe shows **Current page** while this is on. If you attached a page or selection yourself, Sim uses those snapshots instead. Turn the setting off in the same menu. + +If Chrome needs access after you switch tabs, your message stays in the composer. Select **Request site access**. If Chrome hasn’t shared the site address with Sim, paste it into **Site address**. Check the site shown on **Allow access**, approve it in Chrome’s permission dialog, then send again. For one-time access, click the Sim toolbar icon on the active tab and retry. Open **+ → Site access** to remove a saved permission. + +To share a screenshot, drag the image file onto the open chat. It uploads to Sim and appears as an attachment you can remove before sending. If a website supplies only an image URL, paste the image itself or use the file picker. + +For faster access, press **Command + Shift + S** on macOS or **Alt + Shift + S** on Windows and Linux. In version 0.1.3 or later, the shortcut attaches highlighted text when you have a selection. It won't substitute the whole page for an unreadable selection. With no selection, it attaches the page or uses your send-time reference setting. On an already-open sidebar, Chrome may keep keyboard focus on the website. Click the message field, or use Chrome’s pane navigation (on Mac, **Command + Option + Up**). + +If Chrome needs access to a new tab, the sidebar shows the shortcut assigned in this Chrome profile, or **Set shortcut** if none is assigned. You can change it under **Extensions → Keyboard shortcuts**. + +## Roll out to a team + +Share the chat extension’s install link with your team. In managed Chrome, an admin can [allow the extension for an organizational unit](https://support.google.com/chrome/a/answer/6306504) so people can install it themselves. + +If your organization has [Sim Links](/search/sim-links), type `sim`, press **Space**, and enter part of a link name for suggestions. To open a shortcut by typing it directly, include the trailing slash, as in `sim/handbook/`. Teams that already use an internal host named `sim` should keep that hostname conflict in mind: the chat extension routes those navigations to Sim. + +## Page access and privacy + +Sim reads a page or selection when you attach it, or when you send a message with **Reference current page** enabled. For repeat use on one site, choose **+ → Site access** to grant or revoke that site’s permission. Granting access doesn't start background capture. The page text goes to the hosted composer when attached and becomes part of the conversation when you send it. + +See [Sim’s browser extension privacy disclosure](https://www.sim.ai/privacy#browser-extension). diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 0e9fe601cfa..4a9220fc06b 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -5,6 +5,7 @@ "./introduction/index", "./getting-started/index", "./desktop/index", + "./browser-extension/index", "---Sim Search---", "search", "---Build---", @@ -28,6 +29,7 @@ "defaultOpen": false, "labels": { "./desktop/index": "Desktop", + "./browser-extension/index": "Browser extension", "search": "Overview", "logs-debugging": "Logs and debugging" } diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index f45b2fd980b..4fa6a9fc9ab 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -15,15 +15,25 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -With the Sim Chrome extension, type `sim/handbook` in the address bar. To find a link, type `sim`, press **Space**, and start typing its name: matching links appear as you type, most opened first. Unlisted links aren't suggested. +In Sim Cloud, open **Sim Links → Browser shortcuts** to install [Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile. Type `sim` in Chrome’s address bar, press **Space**, and enter part of a link name. Chrome shows matching links, most opened first. Unlisted links can still open by name. + +To open links as `sim/handbook`, copy the **Setup address** from that dialog and paste it into Chrome’s address bar once. This opens your Sim Links directory and lets Chrome remember the short host in that profile. If Chrome sends a shortcut to search, enter `sim/handbook/` with a trailing slash. Use **Copy link** in Sim to share an address that also works without the extension. + +If `sim` + **Space** sends link names to your search engine even when the address bar shows **Sim**, a Chrome site search may already use the `sim` shortcut. Enter `http://sim/handbook` to open the link directly. To restore suggestions, temporarily turn off the Sim extension, rename the old shortcut in `chrome://settings/searchEngines`, then turn Sim back on. If your organization manages the shortcut, ask your administrator to rename it. Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead: 1. Open Chrome **Settings → Search engine → Manage search engines and site search**. 2. Next to **Site search**, select **Add**. -3. Enter `Sim` as the name, `sim` as the shortcut, and `https://www.sim.ai/sim/%s` as the URL. +3. Enter `Sim` as the name, `sim` as the shortcut, and `https://www.sim.ai/sim/%s` as the URL. On a self-hosted deployment, use your own Sim origin. + +Type `sim`, press **Space**, then type a link name and press **Enter**. For managed profiles without the extension, administrators can distribute the shortcut through Chrome’s [site search policy](https://chromeenterprise.google/policies/site-search-settings/) using `https://www.sim.ai/sim/{searchTerms}` as its URL template. -Type `sim`, press **Space**, then type a link name and press **Enter**. Chrome administrators can add the shortcut for everyone with the `SiteSearchSettings` policy. +### Show favorites in regular Chrome suggestions + +In Sim, choose **Copy link** for a favorite. In Chrome’s bookmark manager (`chrome://bookmarks`), add a bookmark with that URL and name it after the shortcut, such as `sim/handbook`. Chrome can suggest that bookmark when you type `sim/handb` or `handbook` in the address bar. Administrators can distribute selected team favorites through Chrome’s [managed bookmarks policy](https://chromeenterprise.google/policies/managed-bookmarks/). + +Organization admins can also open **Browser shortcuts → Team bookmarks**, choose listed links, and select **Export bookmarks**. The downloaded JSON contains a folder to merge into Chrome’s managed bookmarks policy. Its links resolve in the exporting organization, so a bookmark shared outside the team cannot open another team's same-name destination. ## Create a link @@ -31,6 +41,8 @@ Type `sim`, press **Space**, then type a link name and press **Enter**. Chrome a 2. Enter the **Destination URL** and a **Name**. Names use letters, numbers, and `. : @ & ~ - _`; spaces become hyphens as you type. 3. Optionally add a **Description**, then select **Create**. +With Sim for Chrome 0.1.3 or later, right-click an HTTP or HTTPS link on a page and choose **Create Sim Link**. Check the destination and organization, enter a name, then choose **Create**. Sim strips query parameters and fragments from the initial destination. You can edit the address before saving. Closing the draft creates nothing. + To share a link, right-click it and select **Copy link**. ## Add values to a link diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index be04a71d45d..b2fd6ea2244 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -56,7 +56,7 @@ other workspace resources or browser-control tools. - Required browser capabilities: side panel, scripting, active tab, context menus, and short-lived session storage for selection handoff. Required host access is limited to the configured Sim origin and to `http(s)://sim/*`, which exists only so - one static redirect rule can send a typed `sim/` to the Sim link resolver. + one static redirect rule can send top-level navigations to that host to the Sim link resolver. - Other HTTP(S) origins are optional grants requested for the current site from a user gesture. Each grant can be revoked. Keeping the panel open is not a grant to read every subsequently selected tab. @@ -68,7 +68,7 @@ other workspace resources or browser-control tools. configured Sim origin with the signed-in session to suggest matching Sim links, unless it contains a space (a search) or is longer than a link name can be. Page content is never sent, and unlisted links are never suggested. -- No always-running page reader. Extraction is tied to an explicit attachment action. +- Page extraction runs on an explicit attachment action or a user-initiated composer Send while Reference current page is enabled. The preference persists locally. Send-time captures are scoped to the request and conversation; navigation, denied access or timeout cancels the send. - Page bytes are not written to extension local/sync storage. Temporary context-menu handoff uses trusted-context session storage, expiration and navigation cleanup. - Sent snapshots become chat data and follow Sim's chat retention and deletion diff --git a/apps/extension/README.md b/apps/extension/README.md index 93a7729c8b8..0871e2056a1 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -2,6 +2,10 @@ The Chrome extension attaches an explicitly captured page or selection to the existing Sim chat. The native side panel hosts the authenticated `/extension` app surface. Chat opens in the user's organization when available; choosing a workspace is optional. Members without access to Home use the organization's Assistant. Chat rendering, execution, permissions, billing, and history belong to Sim and Mothership. +[User and administrator guide](https://docs.sim.ai/browser-extension) + +The keyboard shortcut prefers highlighted text and preserves the draft when a selection cannot be read. Chrome’s **Extensions → Keyboard shortcuts** page controls the binding per profile. A link’s context menu opens the existing Sim Links creation form with a reviewed destination; only selecting **Create** writes a link. + ## Build and load From the repository root: @@ -26,7 +30,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app ## Data and permissions - Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*`. -- Typing `sim/` in the address bar opens the organization's [Sim link](https://docs.sim.ai/search/sim-links). A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim. +- Type `sim`, press Space, enter the name, and press Enter to open an organization's [Sim link](https://docs.sim.ai/search/sim-links). Chrome can treat bare `sim/` as a search before the redirect rule runs. Typing `http://sim/` once lets Chrome learn the short host in that profile. A trailing slash (`sim//`) also selects navigation without relying on that history. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim. - The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested. - Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. - Page capture runs on demand in Chrome's isolated world. It excludes editable fields, inputs, hidden elements, scripts, and embedded documents. It reads only the top frame. PDF viewers and canvas documents may have no accessible text. @@ -38,6 +42,8 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app - Captures and queued toolbar intents are scoped to the receiving conversation. Changing conversation, workspace, or account discards an unfinished handoff. Captured attachments remain explicit snapshots when the source tab changes. - The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing is delegated only to the configured Sim origin. Clipboard reading and microphone capture are not delegated; voice input remains available in the main app. +With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. The extension stores only the preference in local storage. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. When Chrome hides the active address, **Site access** lets the user enter a site and confirm its origin in Chrome’s permission dialog. The browser grants the selected scheme, host and port. Uploaded screenshot files stay in the hosted app’s normal attachment flow. + ## Verification ```sh diff --git a/apps/extension/package.json b/apps/extension/package.json index e4fa5911ca2..577d69f3edf 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@sim/extension", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/apps/extension/scripts/build.ts b/apps/extension/scripts/build.ts index 626c4fc7b7f..7bd61621109 100644 --- a/apps/extension/scripts/build.ts +++ b/apps/extension/scripts/build.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { build } from 'esbuild' import postcss from 'postcss' import loadPostcssConfig from 'postcss-load-config' +import packageJson from '../package.json' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const origin = new URL(process.env.SIM_EXTENSION_ORIGIN ?? 'https://www.sim.ai') @@ -89,8 +90,8 @@ for (const [size, file] of [ await cp(resolve(root, '../sim/public/favicon', file), resolve(dist, `icon-${size}.png`)) } /** - * Chrome navigates a typed `sim/` to `http://sim/`; this rewrites it to the Sim link - * resolver on the configured origin, keeping the rest of the path and query verbatim. + * Only requests to the short host reach this rule. Chrome can classify bare `sim/` as a + * search before navigation. Preserve the path and query for actual short-host navigations. */ const simLinkRules = [ { @@ -107,7 +108,7 @@ await writeFile( { manifest_version: 3, name: development ? 'Sim (Development)' : 'Sim', - version: '0.1.2', + version: packageJson.version, description: "Ask Sim about the page you are reading, and open your organization's sim/ links from the address bar.", minimum_chrome_version: '120', @@ -132,7 +133,7 @@ await writeFile( commands: { 'open-sim': { suggested_key: { default: 'Alt+Shift+S', mac: 'Command+Shift+S' }, - description: 'Open Sim and attach the current page', + description: 'Ask Sim about selected text or the current page', }, }, content_security_policy: { diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index cb63a401c40..855eac9030b 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -7,7 +7,11 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { createElement } from 'react' import { type BrowserContext, chromium, expect, type Frame, type Page } from '@playwright/test' -import { pageSnapshotSchema } from '@sim/browser-extension-protocol' +import { + type AppToExtensionMessage, + extensionToAppMessageSchema, + pageSnapshotSchema, +} from '@sim/browser-extension-protocol' import { SecretReveal } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -43,6 +47,17 @@ const fixture = Bun.serve({ window.addEventListener('message', event => { if (event.source !== parent || !event.origin.startsWith('chrome-extension://')) return; if (event.data.type === 'e2e-barrier') document.body.dataset.barrierId = event.data.id; + if (event.data.type === 'connected') { + document.body.dataset.connected = event.data.sessionId; + document.body.dataset.preferenceAtConnect = document.body.dataset.reference ?? 'unknown'; + } + if (event.data.sessionId === document.body.dataset.sessionId) { + if (event.data.type === 'reference-preference') document.body.dataset.reference = String(event.data.enabled); + if (event.data.type === 'send-context' || event.data.type === 'send-context-error') { + document.body.dataset.referenceReply = JSON.stringify(event.data); + document.body.dataset.referenceRequest = event.data.requestId; + } + } if (event.data.type === 'context' && event.data.sessionId === document.body.dataset.sessionId) { if (document.body.dataset.ignoreCaptures) return; received.push(event.data.snapshot); @@ -126,7 +141,11 @@ try { context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), { channel: 'chromium', headless: true, - args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`], + args: [ + '--enable-unsafe-extension-debugging', + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], }) const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker')) context.setDefaultTimeout(15_000) @@ -188,14 +207,56 @@ try { const snapshots = async () => pageSnapshotSchema.array().parse(JSON.parse(await app.locator('#received').innerText())) let receiverSessionId = generateId() + const post = (message: AppToExtensionMessage) => + app.evaluate(({ message, origin }) => parent.postMessage(message, origin), { + message, + origin: `chrome-extension://${extensionId}`, + }) + const setReference = async (enabled: boolean) => { + await post({ + channel: 'sim-extension', + version: 1, + sessionId: receiverSessionId, + type: 'set-reference-preference', + enabled, + }) + await expect(app.locator('body')).toHaveAttribute('data-reference', String(enabled)) + } + const referenceRequest = () => + ({ + channel: 'sim-extension', + version: 1, + sessionId: receiverSessionId, + type: 'capture-for-send', + requestId: generateId(), + }) as const + const readReply = async (requestId: string) => { + await expect(app.locator('body')).toHaveAttribute('data-reference-request', requestId) + const raw = await app.locator('body').getAttribute('data-reference-reply') + assert(raw, 'No reply for the current reference request') + const reply = extensionToAppMessageSchema.parse(JSON.parse(raw)) + assert( + (reply.type === 'send-context' || reply.type === 'send-context-error') && + reply.requestId === requestId, + 'Unexpected reference reply' + ) + return reply + } + const reference = async () => { + const message = referenceRequest() + await post(message) + return readReply(message.requestId) + } await app.evaluate((sessionId) => { document.body.dataset.sessionId = sessionId }, receiverSessionId) - const ready = () => { + const ready = async () => { receiverReady = true - return app.evaluate( + await app.evaluate( ({ targetOrigin, sessionId }) => { document.body.dataset.sessionId = sessionId + delete document.body.dataset.connected + delete document.body.dataset.reference parent.postMessage( { channel: 'sim-extension', version: 1, type: 'ready', sessionId }, targetOrigin @@ -203,6 +264,7 @@ try { }, { targetOrigin: `chrome-extension://${extensionId}`, sessionId: receiverSessionId } ) + await expect(app.locator('body')).toHaveAttribute('data-connected', receiverSessionId) } const pendingIntent = () => worker.evaluate(async () => { @@ -240,11 +302,16 @@ try { } await check( - 'A typed sim/ link opens the Sim link resolver on the configured origin', + 'HTTP and HTTPS navigations to the sim host reach the configured resolver', async () => { const tab = await page.context().newPage() for (const scheme of ['http', 'https']) { - for (const path of ['handbook', 'jira/123?view=board', 'jira/a%2Fb?view=board%20view']) { + for (const path of [ + 'handbook', + 'handbook/', + 'jira/123?view=board', + 'jira/a%2Fb?view=board%20view', + ]) { await tab.goto(`${scheme}://sim/${path}`) assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`) } @@ -399,6 +466,45 @@ try { .waitFor() assert((await snapshots()).length === 2, 'Editable content was transmitted') }) + await check( + 'A selection inside a closed shadow field cannot become a whole-page reference', + async () => { + await setReference(true) + await page.evaluate(() => { + const host = document.createElement('div') + document.body.appendChild(host) + const field = document.createElement('input') + field.type = 'password' + field.value = 'PRIVATE_SHADOW_SENTINEL' + host.attachShadow({ mode: 'closed' }).append(field) + field.focus() + field.select() + }) + await capture('Selection') + await expect(panel.getByRole('status')).toContainText('outside an editable or embedded field') + const refused = await reference() + assert( + refused.type === 'send-context-error' && refused.reason === 'selection', + 'A private selection fell back to the surrounding page' + ) + assert((await snapshots()).length === 2, 'Private selection produced an attachment') + const next = await page.context().newPage() + try { + await next.goto(`${pageOrigin}/after-selection`) + await next.bringToFront() + const accepted = await reference() + assert( + accepted.type === 'send-context' && + accepted.snapshot.url === `${pageOrigin}/after-selection`, + 'The previous failed selection blocked a different tab' + ) + } finally { + await next.close() + await page.bringToFront() + await setReference(false) + } + } + ) await check('Large multilingual pages remain bounded and mark truncation', async () => { await page.evaluate(() => { document.body.textContent = '界'.repeat(200_000) @@ -601,6 +707,7 @@ try { await check( 'Composer capture requests require a current user gesture and conversation', async () => { + await setReference(true) const before = (await snapshots()).length await panel.evaluate(() => { let queries = 0 @@ -659,6 +766,15 @@ try { expression: `parent.postMessage(${JSON.stringify(action)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`, userGesture: false, }) + const unactivated = referenceRequest() + await cdp.send('Runtime.evaluate', { + expression: `parent.postMessage(${JSON.stringify(unactivated)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`, + userGesture: false, + }) + assert( + (await readReply(unactivated.requestId)).type === 'send-context-error', + 'Send captured the page without a user gesture' + ) await expect .poll(async () => { const { result } = await parentCdp.send('Runtime.evaluate', { @@ -691,6 +807,7 @@ try { ) await cdp.detach() await parentCdp.detach() + await setReference(false) } ) await check('Ordinary Sim and encoded remote pages attach without sensitive fields', async () => { @@ -823,6 +940,247 @@ try { assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`) } ) + await check( + 'Only the latest conversation connects, after receiving the saved reference preference', + async () => { + await setReference(true) + await panel.evaluate(() => { + const original = chrome.storage.local.get.bind(chrome.storage.local) + chrome.storage.local.get = (async (keys: string) => { + chrome.storage.local.get = original + const result = await original(keys) + document.body.dataset.preferenceRead = 'held' + await new Promise((resolve) => + document.addEventListener('release-preference', () => resolve(), { once: true }) + ) + document.body.dataset.preferenceRead = 'finished' + return result + }) as typeof chrome.storage.local.get + }) + await app.evaluate( + ({ origin, sessionId }) => { + delete document.body.dataset.reference + delete document.body.dataset.connected + document.body.dataset.sessionId = sessionId + parent.postMessage( + { channel: 'sim-extension', version: 1, type: 'ready', sessionId }, + origin + ) + }, + { origin: `chrome-extension://${extensionId}`, sessionId: generateId() } + ) + await expect(panel.locator('body')).toHaveAttribute('data-preference-read', 'held') + await settleMessages() + assert( + (await app.locator('body').getAttribute('data-connected')) === null, + 'Shell connected before its preference was known' + ) + receiverSessionId = generateId() + await ready() + await expect(app.locator('body')).toHaveAttribute('data-preference-at-connect', 'true') + await panel.evaluate(() => document.dispatchEvent(new Event('release-preference'))) + await expect(panel.locator('body')).toHaveAttribute('data-preference-read', 'finished') + await settleMessages() + await expect(app.locator('body')).toHaveAttribute('data-connected', receiverSessionId) + await setReference(false) + } + ) + await check('A failed preference read keeps manual attachment available', async () => { + await panel.evaluate(() => { + const get = chrome.storage.local.get.bind(chrome.storage.local) + chrome.storage.local.get = (() => { + chrome.storage.local.get = get + return Promise.reject(new Error('Storage unavailable')) + }) as typeof chrome.storage.local.get + }) + receiverSessionId = generateId() + await ready() + await expect(app.locator('body')).toHaveAttribute('data-connected', receiverSessionId) + await expect(app.locator('body')).toHaveAttribute('data-preference-at-connect', 'false') + await expect(panel.getByRole('status')).toContainText('Attach the page manually') + const before = (await snapshots()).length + await page.goto(`${origin}/workspace/example`) + await page.bringToFront() + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(before + 1) + }) + await check( + 'Toolbar access on a new site allows an explicit retry without pinning it in Reference mode', + async () => { + const browser = page.context().browser() + assert(browser, 'Missing Chromium browser') + const cdp = await browser.newBrowserCDPSession() + try { + await setReference(true) + await page + .context() + .route('https://reference-source.example/**', (route) => + route.fulfill({ contentType: 'text/html', body: '

NEW_REFERENCE_SOURCE

' }) + ) + await page.goto('https://reference-source.example/new-source?token=PRIVATE#fragment') + await page.bringToFront() + assert((await reference()).type === 'send-context-error', 'An ungranted site was captured') + await panel.evaluate(() => { + let intents = 0 + document.body.dataset.toolbarIntents = '0' + chrome.storage.onChanged.addListener((changes, area) => { + if ( + area === 'session' && + Object.entries(changes).some( + ([key, change]) => key.startsWith('captureIntent:') && change.newValue + ) + ) + document.body.dataset.toolbarIntents = String(++intents) + }) + }) + const targets = await cdp.send('Target.getTargets', { filter: [{ type: 'tab' }] }) + const target = targets.targetInfos.find((item) => item.url === page.url()) + assert(target, 'Missing toolbar action target') + const action = { id: extensionId, targetId: target.targetId } + await cdp.send('Extensions.triggerAction', action) + const retried = await reference() + assert( + retried.type === 'send-context' && retried.snapshot.text.includes('NEW_REFERENCE_SOURCE'), + 'Toolbar access did not allow the explicit retry' + ) + assert( + retried.snapshot.url === 'https://reference-source.example/new-source', + 'Reference retained a private URL parameter' + ) + await expect(panel.locator('body')).toHaveAttribute('data-toolbar-intents', '0') + await setReference(false) + await cdp.send('Extensions.triggerAction', action) + await expect(panel.locator('body')).toHaveAttribute('data-toolbar-intents', '1') + } finally { + await cdp.detach() + await page.context().unroute('https://reference-source.example/**') + } + } + ) + await check( + 'Switching away and back during extraction discards the send-time reference', + async () => { + await setReference(true) + await page.goto(`${pageOrigin}/switch-source`) + await page.bringToFront() + await panel.evaluate(() => { + const original = chrome.scripting.executeScript.bind(chrome.scripting) + // Hold the real Chrome result while the source changes, even on a fast runner. + chrome.scripting.executeScript = (async ( + injection: chrome.scripting.ScriptInjection + ) => { + chrome.scripting.executeScript = original + const result = await original(injection) + document.body.dataset.extraction = 'complete' + await new Promise((resolve) => + document.addEventListener('release-extraction', () => resolve(), { once: true }) + ) + return result + }) as typeof chrome.scripting.executeScript + }) + const pending = reference() + await expect(panel.locator('body')).toHaveAttribute('data-extraction', 'complete') + const alternate = await page.context().newPage() + try { + await alternate.goto(`${pageOrigin}/alternate`) + await alternate.bringToFront() + await page.bringToFront() + } finally { + await alternate.close() + await panel.evaluate(() => document.dispatchEvent(new Event('release-extraction'))) + } + const reply = await pending + assert( + reply.type === 'send-context-error' && reply.message.includes('changed'), + 'Returning to the same tab released stale page content' + ) + await setReference(false) + } + ) + await check( + 'Site access requests only the address the user confirms, without reading the page', + async () => { + const source = 'https://private-source.example' + const privateUrl = `${source}/sensitive-path?token=PRIVATE_URL_SENTINEL#fragment` + await page + .context() + .route(`${source}/**`, (route) => + route.fulfill({ contentType: 'text/html', body: '

PRIVATE_CONTENT_SENTINEL

' }) + ) + await page.goto(privateUrl) + await page.bringToFront() + await setReference(true) + const before = (await snapshots()).length + const denied = await reference() + assert(denied.type === 'send-context-error', 'Reference read the private site without access') + assert( + !denied.message.includes('private-source') && !denied.message.includes('PRIVATE'), + 'Access failure exposed the private URL' + ) + await panel.evaluate(() => { + const requests: chrome.permissions.Permissions[] = [] + const request = chrome.permissions.request.bind(chrome.permissions) + chrome.permissions.request = ((permissions: chrome.permissions.Permissions) => { + requests.push(permissions) + document.body.dataset.requestedAccess = JSON.stringify(requests) + document.body.dataset.requestGesture = String(navigator.userActivation.isActive) + return request(permissions) + }) as typeof chrome.permissions.request + const execute = chrome.scripting.executeScript.bind(chrome.scripting) + chrome.scripting.executeScript = ((...args: Parameters) => { + document.body.dataset.unrequestedRead = 'true' + return execute(...args) + }) as typeof chrome.scripting.executeScript + }) + await panel.getByRole('button', { name: 'Request site access' }).click() + const dialog = panel.getByRole('dialog', { name: 'Site access' }) + await expect(dialog).toBeVisible() + const address = dialog.getByRole('textbox', { name: 'Site address' }) + await expect(address).toHaveValue('') + const allow = dialog.getByRole('button', { name: /^Allow access/ }) + for (const unsafe of [ + 'https://user:password@example.com/', + 'https://*.example.com/', + 'https://%2A.example.com/', + 'https://*.example.com/', + '', + 'chrome://settings/', + ]) { + await address.fill(unsafe) + await expect(allow, `Do not offer to grant access for ${unsafe}`).toHaveAccessibleName( + 'Allow access' + ) + await expect(allow).toBeDisabled() + } + await address.fill('http://intranet.example:8080/private-path') + await expect(allow).toHaveAccessibleName('Allow access to http://intranet.example:8080') + const allowedHost = 'https://approved-site.example' + await address.fill(`${allowedHost}/private-path?token=INPUT_SECRET#fragment`) + await expect(allow).toHaveAccessibleName(`Allow access to ${allowedHost}`) + assert( + (await panel.locator('body').getAttribute('data-requested-access')) === null, + 'Typing initiated a permission request' + ) + const grantedSites = () => + worker.evaluate(async () => ((await chrome.permissions.getAll()).origins ?? []).sort()) + const previousAccess = await grantedSites() + await allow.click() + await expect(panel.locator('body')).toHaveAttribute('data-request-gesture', 'true') + const requested = await panel.locator('body').getAttribute('data-requested-access') + assert(requested, 'The user action never reached Chrome') + expect(JSON.parse(requested)).toEqual([{ origins: ['https://approved-site.example:443/*'] }]) + expect(await grantedSites(), 'A site received persistent access without approval').toEqual( + previousAccess + ) + await settleMessages() + assert( + (await panel.locator('body').getAttribute('data-unrequested-read')) === null, + 'Managing site access attempted to read page content' + ) + assert((await snapshots()).length === before, 'Managing site access attached a page') + await expect(app.locator('body')).toHaveAttribute('data-reference-request', denied.requestId) + } + ) await mkdir(dirname(output), { recursive: true }) await panel.screenshot({ path: output.replace(/\.json$/, '.png'), fullPage: true }) } catch (error) { diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index c6585bc53dc..05899a29cf3 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -1,15 +1,24 @@ +import { SIM_LINK_DRAFT_PATH, simLinkDraftDestination } from '@sim/browser-extension-protocol' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { CAPTURE_INTENT_KEY, captureIntentKey } from '@/config' +import { + CAPTURE_INTENT_KEY, + captureIntentKey, + OPEN_SIM_COMMAND, + REFERENCE_PAGE_KEY, + SIM_ORIGIN, +} from '@/config' import { captureIntentSchema } from '@/intent' import { registerSimLinkOmnibox } from '@/omnibox' import { digestUrl } from '@/utils' const logger = createLogger('BrowserExtension') const SELECTION_MENU = 'ask-sim-selection' +const LINK_MENU = 'create-sim-link' async function configure() { await chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }) + await chrome.storage.local.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }) await chrome.contextMenus.removeAll() chrome.contextMenus.create({ id: SELECTION_MENU, @@ -17,9 +26,16 @@ async function configure() { contexts: ['selection'], documentUrlPatterns: ['http://*/*', 'https://*/*'], }) + chrome.contextMenus.create({ + id: LINK_MENU, + title: 'Create Sim Link', + contexts: ['link'], + documentUrlPatterns: ['http://*/*', 'https://*/*'], + targetUrlPatterns: ['http://*/*', 'https://*/*'], + }) } -function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') { +function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') { if (tab.incognito) return // Chrome requires open() in the original user-gesture task, before any await. const opened = chrome.sidePanel.open({ windowId: tab.windowId }) @@ -31,17 +47,40 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') { const identity = chrome.scripting.executeScript({ target: { tabId: tab.id, frameIds: [0] }, world: 'ISOLATED', - func: () => location.href, + func: (keyboard: boolean) => { + if (!keyboard) return { href: location.href, selected: false } + const active = document.activeElement + const protectedFocus = + active instanceof HTMLElement && + (active.isContentEditable || + active.matches('iframe,frame,input,textarea,select') || + active.localName.includes('-') || + Boolean(chrome.dom.openOrClosedShadowRoot(active)?.activeElement)) + return { + href: location.href, + selected: protectedFocus || window.getSelection()?.isCollapsed === false, + } + }, + args: [trigger === 'keyboard'], }) - void Promise.all([opened, identity, digestUrl(tab.url)]) - .then(([, result, urlDigest]) => { + const referenceEnabled = + trigger !== 'selection' && + chrome.storage.local + .get(REFERENCE_PAGE_KEY) + .then((values) => values[REFERENCE_PAGE_KEY] === true) + .catch(() => false) + void Promise.all([opened, identity, digestUrl(tab.url), referenceEnabled]) + .then(([, result, urlDigest, enabled]) => { const document = result[0] - if (!document?.documentId || document.result !== tab.url) return + if (!document?.documentId || !document.result || document.result.href !== tab.url) return + const kind = trigger === 'selection' || document.result.selected ? 'selection' : 'page' + if (enabled && kind === 'page' && trigger !== 'keyboard') return return chrome.storage.session.set({ [captureIntentKey(tab.windowId, generateId())]: { tabId: tab.id, windowId: tab.windowId, - kind, + kind: enabled && kind === 'page' ? 'focus' : kind, + focusComposer: trigger === 'keyboard', createdAt, documentId: document.documentId, urlDigest, @@ -57,10 +96,21 @@ chrome.runtime.onInstalled.addListener(() => { }) chrome.action.onClicked.addListener((tab) => open(tab, 'page')) chrome.contextMenus.onClicked.addListener((info, tab) => { - if (info.menuItemId === SELECTION_MENU && tab && !info.editable) open(tab, 'selection') + if (!tab || tab.incognito || info.editable) return + if (info.menuItemId === LINK_MENU && info.linkUrl) { + const destination = simLinkDraftDestination(info.linkUrl) + if (destination) { + const draft = new URL(SIM_LINK_DRAFT_PATH, SIM_ORIGIN) + draft.hash = new URLSearchParams({ url: destination }).toString() + void chrome.tabs + .create({ url: draft.href, windowId: tab.windowId }) + .catch(() => logger.warn('Could not open the Sim Link draft')) + } + } + if (info.menuItemId === SELECTION_MENU) open(tab, 'selection') }) chrome.commands.onCommand.addListener((command, tab) => { - if (command === 'open-sim' && tab) open(tab, 'page') + if (command === OPEN_SIM_COMMAND && tab) open(tab, 'keyboard') }) function clearTabIntent(tabId: number) { void chrome.storage.session diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index dbd5d253f50..984874204a9 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -8,25 +8,98 @@ import { SIM_ORIGIN } from '@/config' import { extractPageText } from '@/extract' import { digestUrl } from '@/utils' +/** A public capture error whose message is safe to share with the hosted composer. */ +export class PageCaptureError extends Error {} + +/** Chrome scripting failures may contain the full private URL of the tab. */ +export function captureErrorMessage(error: unknown): string { + return error instanceof PageCaptureError + ? error.message + : 'This page could not be read. Check site access and try again.' +} + +/** Only emitted after verifying the selected document is still active. */ +export class UnreadableSelectionError extends PageCaptureError { + constructor( + message: string, + readonly tabId: number, + readonly windowId: number + ) { + super(message) + } +} + +export class SiteAccessRequiredError extends PageCaptureError { + constructor( + readonly tabId: number, + readonly windowId: number + ) { + super('Allow site access to attach this page.') + } +} + /** Captures only the focused tab and rejects navigation or tab changes during extraction. */ export async function captureActivePage( kind: 'page' | 'selection', - expected?: { tabId: number; documentId: string; urlDigest: string } + expected?: { tabId: number; documentId: string; urlDigest: string }, + signal?: AbortSignal ) { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - if (tab?.id === undefined || !tab.url || (expected !== undefined && expected.tabId !== tab.id)) { - throw new Error('Activate the page and click the Sim toolbar icon to allow access.') + signal?.throwIfAborted() + if (tab?.id === undefined || tab.incognito) + throw new PageCaptureError('This page cannot be attached.') + if (expected && expected.tabId !== tab.id) { + throw new PageCaptureError('The page changed before reading. Attach it again.') + } + if (!tab.url) throw new SiteAccessRequiredError(tab.id, tab.windowId) + let changed = false + const activated = (active: chrome.tabs.OnActivatedInfo) => { + if (active.windowId === tab.windowId && active.tabId !== tab.id) changed = true } + const updated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { + if (tabId === tab.id && (change.status === 'loading' || change.url !== undefined)) + changed = true + } + chrome.tabs.onActivated.addListener(activated) + chrome.tabs.onUpdated.addListener(updated) + const removeListeners = () => { + chrome.tabs.onActivated.removeListener(activated) + chrome.tabs.onUpdated.removeListener(updated) + signal?.removeEventListener('abort', removeListeners) + } + signal?.addEventListener('abort', removeListeners, { once: true }) + try { + const snapshot = await readTab({ tabId: tab.id, url: tab.url }, kind, expected, signal) + if (changed) throw new PageCaptureError('The page changed while reading. Attach it again.') + return snapshot + } catch (error) { + if (changed) throw new PageCaptureError('The page changed while reading. Attach it again.') + if (expected && !(error instanceof PageCaptureError)) + throw new PageCaptureError( + 'The original document cannot be read. Attach the current page again.' + ) + throw error + } finally { + removeListeners() + } +} + +async function readTab( + tab: { tabId: number; url: string }, + kind: 'page' | 'selection', + expected?: { documentId: string; urlDigest: string }, + signal?: AbortSignal +) { const url = new URL(tab.url) if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { - throw new Error('The page changed before reading. Attach it again.') + throw new PageCaptureError('The page changed before reading. Attach it again.') } let pathname = url.pathname if (url.origin === SIM_ORIGIN) { try { pathname = decodeURIComponent(pathname) } catch { - throw new Error('This page cannot be attached.') + throw new PageCaptureError('This page cannot be attached.') } } if ( @@ -34,32 +107,39 @@ export async function captureActivePage( (url.origin === SIM_ORIGIN && (pathname === '/extension' || pathname.startsWith('/extension/'))) || url.username || - url.password || - tab.incognito + url.password ) { - throw new Error('This page cannot be attached.') + throw new PageCaptureError('This page cannot be attached.') } const results = await chrome.scripting.executeScript({ target: expected - ? { tabId: tab.id, documentIds: [expected.documentId] } - : { tabId: tab.id, frameIds: [0] }, + ? { tabId: tab.tabId, documentIds: [expected.documentId] } + : { tabId: tab.tabId, frameIds: [0] }, world: 'ISOLATED', func: extractPageText, args: [kind, PAGE_SNAPSHOT_MAX_TEXT_LENGTH, PAGE_SNAPSHOT_MAX_BYTES], }) const result = results[0] - if (!result?.documentId || !result.result) throw new Error('This page could not be read.') - const [current] = await chrome.tabs.query({ active: true, currentWindow: true }) - if (current?.id !== tab.id || current.url !== tab.url || current.status === 'loading') { - throw new Error('The page changed while reading. Attach it again.') - } + signal?.throwIfAborted() + if (!result?.documentId || !result.result) + throw new PageCaptureError('This page could not be read.') const identity = await chrome.scripting.executeScript({ - target: { tabId: tab.id, documentIds: [result.documentId] }, + target: { tabId: tab.tabId, documentIds: [result.documentId] }, func: () => location.href, world: 'ISOLATED', }) if (identity[0]?.documentId !== result.documentId || identity[0]?.result !== tab.url) { - throw new Error('The page changed while reading. Attach it again.') + throw new PageCaptureError('The page changed while reading. Attach it again.') + } + const [current] = await chrome.tabs.query({ active: true, currentWindow: true }) + signal?.throwIfAborted() + if (current?.id !== tab.tabId || current.url !== tab.url || current.status === 'loading') { + throw new PageCaptureError('The page changed while reading. Attach it again.') + } + if (result.result.error) { + if (kind === 'selection') + throw new UnreadableSelectionError(result.result.error, current.id, current.windowId) + throw new PageCaptureError(result.result.error) } return pageSnapshotSchema.parse({ ...result.result, id: generateId() }) } diff --git a/apps/extension/src/config.ts b/apps/extension/src/config.ts index b96cb152715..b6d38c9745b 100644 --- a/apps/extension/src/config.ts +++ b/apps/extension/src/config.ts @@ -1,5 +1,7 @@ declare const __SIM_ORIGIN__: string export const SIM_ORIGIN = __SIM_ORIGIN__ +export const REFERENCE_PAGE_KEY = 'referenceCurrentPage' +export const OPEN_SIM_COMMAND = 'open-sim' export const CAPTURE_INTENT_KEY = 'captureIntent' export const CAPTURE_INTENT_TTL_MS = 30_000 export const captureIntentKey = (windowId: number, intentId: string) => diff --git a/apps/extension/src/extract.ts b/apps/extension/src/extract.ts index 5b04198d251..8d0ff20c040 100644 --- a/apps/extension/src/extract.ts +++ b/apps/extension/src/extract.ts @@ -7,19 +7,31 @@ export function extractPageText( const startedAt = performance.now() const source = new URL(location.href) if (!['http:', 'https:'].includes(source.protocol) || source.username || source.password) { - throw new Error('This page cannot be attached.') + return { error: 'This page cannot be attached.' } } source.search = '' source.hash = '' if (kind === 'selection' && document.activeElement?.matches('iframe,frame')) - throw new Error('Open the embedded page in its own tab to attach selected text.') + return { + error: 'The selection could not be read from an embedded frame. Open it in its own tab.', + } + const active = document.activeElement + if ( + kind === 'selection' && + active instanceof HTMLElement && + (active.isContentEditable || + active.matches('input,textarea,select') || + active.localName.includes('-') || + chrome.dom.openOrClosedShadowRoot(active)?.activeElement) + ) + return { error: 'Select text outside an editable or embedded field.' } const selection = kind === 'selection' ? window.getSelection() : null if (kind === 'selection' && (!selection || selection.isCollapsed || selection.rangeCount !== 1)) { - throw new Error('Select text on the page, then choose Ask Sim from the context menu.') + return { error: 'Select text on the page, then attach the selection.' } } const range = selection?.getRangeAt(0) const root = range?.commonAncestorContainer ?? document.body - if (!root) throw new Error('This page has no readable text.') + if (!root) return { error: 'This page has no readable text.' } const excluded = 'script,style,noscript,template,iframe,object,embed,canvas,svg,input,textarea,select,button,[contenteditable]:not([contenteditable="false"]),[hidden],[aria-hidden="true"],[data-sim-extension-ignore]' @@ -37,12 +49,11 @@ export function extractPageText( let ancestor = root.nodeType === Node.ELEMENT_NODE ? (root as Element) : root.parentElement while (ancestor) { if (performance.now() - startedAt > 150) - throw new Error('Reading this page took too long. Select a smaller section.') + return { error: 'The selection could not be read in time. Select a smaller section.' } if (++visited > 15_000) - throw new Error( - 'This selection is too deeply nested to capture safely. Select a smaller section.' - ) - if (hidden(ancestor)) throw new Error('This selection is inside an editable or hidden field.') + return { error: 'The selection could not be read safely. Select a smaller section.' } + if (hidden(ancestor)) + return { error: 'The selection could not be read from an editable or hidden field.' } ancestor = ancestor.parentElement } @@ -101,7 +112,7 @@ export function extractPageText( } const text = parts.join('\n') if (!text) - throw new Error('No readable text was found. This page may use a document viewer or canvas.') + return { error: 'No readable text was found. This page may use a document viewer or canvas.' } return { url: source.href, title: document.title.slice(0, 512), diff --git a/apps/extension/src/intent.ts b/apps/extension/src/intent.ts index a3732988ad8..4ad5cecb08d 100644 --- a/apps/extension/src/intent.ts +++ b/apps/extension/src/intent.ts @@ -3,7 +3,8 @@ import { z } from 'zod' export const captureIntentSchema = z.strictObject({ tabId: z.number().int(), windowId: z.number().int(), - kind: z.enum(['page', 'selection']), + kind: z.enum(['page', 'selection', 'focus']), + focusComposer: z.boolean().optional(), createdAt: z.number(), documentId: z.string().min(1).max(128), urlDigest: z.string().regex(/^[a-f0-9]{64}$/), diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts index af6a68cea80..008f9b9a3b7 100644 --- a/apps/extension/src/omnibox.ts +++ b/apps/extension/src/omnibox.ts @@ -12,6 +12,9 @@ const logger = createLogger('BrowserExtensionOmnibox') /** Longer than the gap between a typist's keystrokes, so a name is looked up once it settles. */ const SUGGEST_DELAY_MS = 220 +const SEARCH_HINT = 'Find a Sim link' +// Chrome persists this template and substitutes the input when rendering the default row. +const OPEN_LINK_HINT = 'Open %s in Sim' /** A Sim link as suggested: its name, and what it shows beside the name. */ interface SuggestedLink { @@ -117,14 +120,26 @@ export function registerSimLinkOmnibox() { */ const linksByName = new LRUCache({ max: 100 }) - chrome.omnibox.onInputStarted.addListener(() => linksByName.clear()) + let defaultHint: string | undefined + function showHint(hint: string) { + if (hint === defaultHint) return + defaultHint = hint + chrome.omnibox.setDefaultSuggestion({ description: hint }) + } + + function resetSession() { + pending?.abort() + linksByName.clear() + showHint(SEARCH_HINT) + } + + showHint(SEARCH_HINT) + chrome.omnibox.onInputStarted.addListener(resetSession) chrome.omnibox.onInputChanged.addListener((text, suggest) => { pending?.abort() const { name, rest } = readTyped(text) - chrome.omnibox.setDefaultSuggestion({ - description: `Open sim/${escapeXml(`${name}${rest}`)}`, - }) + showHint(name || rest ? OPEN_LINK_HINT : SEARCH_HINT) // A name can't hold a space, so `sim card prices` is a search, not a link: ask Sim nothing. if (/\s/.test(name) || name.length > SIM_LINK_NAME_MAX_LENGTH) { suggest([]) @@ -144,17 +159,18 @@ export function registerSimLinkOmnibox() { // Signed out or rate limited: the typed link still opens through the resolver, and the name // is asked about again on the next keystroke rather than remembered as having no links. const links = await fetchSuggestedLinks(key, controller.signal) + if (controller.signal.aborted) return if (links) linksByName.set(key, links) - if (!controller.signal.aborted) suggest(toSuggestions(links ?? [], rest)) + suggest(toSuggestions(links ?? [], rest)) })().catch(() => { if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions') }) }) - chrome.omnibox.onInputCancelled.addListener(() => pending?.abort()) + chrome.omnibox.onInputCancelled.addListener(resetSession) chrome.omnibox.onInputEntered.addListener((text, disposition) => { - pending?.abort() + resetSession() const url = simLinkUrl(text) const opened = disposition === 'currentTab' diff --git a/apps/extension/src/reference.ts b/apps/extension/src/reference.ts new file mode 100644 index 00000000000..eafd28b586d --- /dev/null +++ b/apps/extension/src/reference.ts @@ -0,0 +1,185 @@ +import { + type ExtensionToAppMessage, + PAGE_REFERENCE_TIMEOUT_MS, +} from '@sim/browser-extension-protocol' +import { LRUCache } from 'lru-cache' +import { + captureActivePage, + captureErrorMessage, + PageCaptureError, + SiteAccessRequiredError, +} from '@/capture' +import { REFERENCE_PAGE_KEY } from '@/config' + +interface ReferenceOptions { + session: () => string | null + post: (message: ExtensionToAppMessage) => void + manualCaptureError: () => { message: string; reason?: 'selection' } | undefined + showStatus: (message: string | SiteAccessRequiredError) => void + clearAccessStatus: () => void +} + +/** Correlates an explicit Send with a single active-page read in the current conversation. */ +export function createReferenceCapture({ + session, + post, + manualCaptureError, + showStatus, + clearAccessStatus, +}: ReferenceOptions) { + const envelope = { channel: 'sim-extension', version: 1 } as const + const requests = new LRUCache({ max: 64, ttl: PAGE_REFERENCE_TIMEOUT_MS }) + let pending: { sessionId: string; requestId: string; controller: AbortController } | null = null + let enabled = false + let revision = 0 + let preferenceWrite: Promise | null = null + + function replyError( + sessionId: string, + requestId: string, + error: string | SiteAccessRequiredError, + reason?: 'selection' + ) { + if (session() !== sessionId) return + const message = typeof error === 'string' ? error : error.message + if (typeof error !== 'string' || reason) showStatus(error) + post({ + ...envelope, + type: 'send-context-error', + sessionId, + requestId, + message, + ...(typeof error !== 'string' ? { reason: 'site-access' as const } : reason && { reason }), + }) + } + + function cancel(message?: string) { + if (!pending) return + pending.controller.abort() + if (message) replyError(pending.sessionId, pending.requestId, message) + pending = null + } + + function publishPreference(next: boolean) { + enabled = next + if (!next) cancel('Reference current page was turned off. Send again to continue.') + const sessionId = session() + if (sessionId) post({ ...envelope, type: 'reference-preference', sessionId, enabled }) + } + + async function refreshPreference(sessionId = session()): Promise { + while (sessionId && session() === sessionId) { + const current = revision + await preferenceWrite + try { + const values = await chrome.storage.local.get(REFERENCE_PAGE_KEY) + if (session() !== sessionId) return false + if (current !== revision || preferenceWrite) continue + publishPreference(values[REFERENCE_PAGE_KEY] === true) + return true + } catch { + if (session() !== sessionId) return false + if (current !== revision || preferenceWrite) continue + publishPreference(false) + showStatus( + 'Page reference is unavailable. Attach the page manually or reopen Sim to try again.' + ) + return true + } + } + return false + } + + async function setPreference(next: boolean) { + if (!navigator.userActivation.isActive) { + publishPreference(enabled) + return + } + // A disable takes effect before the storage change event, including for a read in progress. + revision++ + if (!next) { + clearAccessStatus() + publishPreference(false) + } + const write = chrome.storage.local.set({ [REFERENCE_PAGE_KEY]: next }).catch(() => { + showStatus('Could not save the page reference setting.') + }) + preferenceWrite = write + await write + if (preferenceWrite === write) { + preferenceWrite = null + await refreshPreference() + } + } + + async function capture(sessionId: string, requestId: string) { + if (requests.has(requestId)) return + requests.set(requestId, true) + if (!navigator.userActivation.isActive || !enabled) { + replyError(sessionId, requestId, 'Send again with Reference current page enabled.') + return + } + const manualError = manualCaptureError() + if (pending || manualError) { + replyError( + sessionId, + requestId, + manualError?.message ?? + 'A page attachment is still being prepared. Try again once it appears.', + manualError?.reason + ) + return + } + const request = { sessionId, requestId, controller: new AbortController() } + pending = request + const started = performance.now() + const expired = 'The page reference expired. Send again to continue.' + const timeout = window.setTimeout(() => { + if (pending === request) cancel(expired) + }, PAGE_REFERENCE_TIMEOUT_MS) + const assertCurrent = () => { + if ( + pending !== request || + session() !== sessionId || + !enabled || + performance.now() - started >= PAGE_REFERENCE_TIMEOUT_MS + ) + throw new PageCaptureError(expired) + } + try { + const snapshot = await captureActivePage('page', undefined, request.controller.signal) + assertCurrent() + clearAccessStatus() + post({ ...envelope, type: 'send-context', sessionId, requestId, snapshot }) + } catch (error) { + if (pending !== request || session() !== sessionId) return + replyError( + sessionId, + requestId, + error instanceof SiteAccessRequiredError ? error : captureErrorMessage(error) + ) + } finally { + window.clearTimeout(timeout) + if (pending === request) pending = null + } + } + + return { + capture, + refreshPreference, + setPreference, + busy: () => pending !== null, + manualRequested: () => + cancel('An explicit attachment was requested. Try again after it appears.'), + preferenceChanged(value: unknown) { + if (preferenceWrite) return + revision++ + publishPreference(value === true) + }, + reset() { + revision++ + cancel() + requests.clear() + }, + } +} diff --git a/apps/extension/src/shortcut-hint.tsx b/apps/extension/src/shortcut-hint.tsx new file mode 100644 index 00000000000..543390bdb1c --- /dev/null +++ b/apps/extension/src/shortcut-hint.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import { OPEN_SIM_COMMAND } from '@/config' + +/** Reads the binding in this Chrome profile, including changes made in the shortcut settings. */ +export function ShortcutHint() { + const [shortcut, setShortcut] = useState(null) + useEffect(() => { + let revision = 0 + const read = () => { + const current = ++revision + void chrome.commands + .getAll() + .then((commands) => { + if (current === revision) + setShortcut(commands.find((entry) => entry.name === OPEN_SIM_COMMAND)?.shortcut ?? '') + }) + .catch(() => { + if (current === revision) setShortcut(null) + }) + } + read() + window.addEventListener('focus', read) + return () => { + revision++ + window.removeEventListener('focus', read) + } + }, []) + if (shortcut === null) return null + return ( +

+ {shortcut ? ( + <> + For one-time access, press {shortcut} on the page. + + ) : ( + + )} +

+ ) +} diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index 5e6114cecdd..3e9a07a937a 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -4,43 +4,76 @@ import { type ExtensionToAppMessage, type PageSnapshot, } from '@sim/browser-extension-protocol' -import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' +import { Chip } from '@sim/emcn' import { createRoot } from 'react-dom/client' -import { captureActivePage } from '@/capture' -import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, SIM_ORIGIN } from '@/config' +import { + captureActivePage, + captureErrorMessage, + PageCaptureError, + SiteAccessRequiredError, + UnreadableSelectionError, +} from '@/capture' +import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config' import { captureIntentSchema } from '@/intent' +import { createReferenceCapture } from '@/reference' +import { ShortcutHint } from '@/shortcut-hint' +import { SiteAccessDialog } from '@/site-access' import '@/shell.css' +const BLOCKED_SELECTION = + 'Select readable text again, attach the page explicitly, or turn off Reference current page.' +const KEYBOARD_FOCUS_HINT = navigator.platform.startsWith('Mac') + ? 'To type here, click the message field or press ⌘⌥↑ to move focus.' + : 'To type here, click the message field or use F6 to move focus.' + interface SidePanelProps { origin: string } -/** Native modality avoids the inline scroll-lock stylesheet blocked by the extension CSP. */ -function mountAccessDialog(dialog: HTMLDialogElement | null) { - if (!dialog) return - dialog.showModal() - return () => dialog.close() -} - function SidePanel({ origin }: SidePanelProps) { const frameRef = useRef(null) const pendingRef = useRef(null) + const focusRequested = useRef(false) + const unreadableSelection = useRef<{ tabId: number; windowId: number } | null>(null) const sessionRef = useRef(null) + const connectedRef = useRef(false) const deliveredRef = useRef(null) const captureGenerationRef = useRef(0) const intentCutoffRef = useRef(0) const busyRef = useRef(false) const [accessOpen, setAccessOpen] = useState(false) - const [status, setStatus] = useState('') - const [site, setSite] = useState<{ origin: string; allowed: boolean } | null>(null) + const [status, setStatus] = useState('') + const referenceRef = useRef | null>(null) + + useEffect(() => { + if (!(status instanceof SiteAccessRequiredError)) return + const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { + if (tabId === status.tabId && (change.status === 'loading' || change.url !== undefined)) { + setStatus((current) => + current === status ? 'The page changed. Try attaching it again.' : current + ) + } + } + const activated = (tab: chrome.tabs.OnActivatedInfo) => { + if (tab.windowId === status.windowId && tab.tabId !== status.tabId) + setStatus((current) => (current === status ? '' : current)) + } + chrome.tabs.onActivated.addListener(activated) + chrome.tabs.onUpdated.addListener(navigated) + return () => { + chrome.tabs.onActivated.removeListener(activated) + chrome.tabs.onUpdated.removeListener(navigated) + } + }, [status]) function deliver() { const snapshot = pendingRef.current const sessionId = sessionRef.current - if (!snapshot || !sessionId || deliveredRef.current === snapshot.id) return + if (!connectedRef.current || !snapshot || !sessionId || deliveredRef.current === snapshot.id) + return if (Date.now() - Date.parse(snapshot.capturedAt) > CAPTURE_INTENT_TTL_MS) { pendingRef.current = null + focusRequested.current = false setStatus('The capture expired. Attach the page again.') return } @@ -55,7 +88,25 @@ function SidePanel({ origin }: SidePanelProps) { deliveredRef.current = snapshot.id } + function deliverFocus() { + if (!focusRequested.current || !connectedRef.current || !sessionRef.current) return + focusRequested.current = false + if (document.querySelector('dialog[open]')) return + const message: ExtensionToAppMessage = { + channel: 'sim-extension', + version: 1, + type: 'focus-composer', + sessionId: sessionRef.current, + } + frameRef.current?.contentWindow?.postMessage(message, SIM_ORIGIN) + if (!document.hasFocus()) setStatus(KEYBOARD_FOCUS_HINT) + } + function invalidateSession() { + unreadableSelection.current = null + focusRequested.current = false + referenceRef.current?.reset() + setStatus((status) => (typeof status === 'string' ? status : '')) intentCutoffRef.current = Date.now() setAccessOpen(false) if (pendingRef.current || busyRef.current) @@ -64,13 +115,24 @@ function SidePanel({ origin }: SidePanelProps) { pendingRef.current = null deliveredRef.current = null sessionRef.current = null + connectedRef.current = false busyRef.current = false } async function capture( kind: 'page' | 'selection', - expected?: { tabId: number; documentId: string; urlDigest: string } + expected?: { + tabId: number + documentId: string + urlDigest: string + windowId: number + focusComposer?: boolean + } ) { + if (referenceRef.current?.busy()) { + setStatus('Wait for the current message to finish attaching its page.') + return + } if (busyRef.current) return if (pendingRef.current) { setStatus('Waiting for Sim to receive the previous capture. Try again after it appears.') @@ -82,17 +144,15 @@ function SidePanel({ origin }: SidePanelProps) { try { const snapshot = await captureActivePage(kind, expected) if (generation !== captureGenerationRef.current) return + focusRequested.current = expected?.focusComposer === true + unreadableSelection.current = null pendingRef.current = snapshot setStatus('Waiting for Sim to receive the attachment…') deliver() } catch (error) { - if (generation === captureGenerationRef.current) - setStatus( - getErrorMessage( - error, - 'Could not read this page. Click the Sim toolbar icon and try again.' - ) - ) + if (generation !== captureGenerationRef.current) return + if (error instanceof UnreadableSelectionError) unreadableSelection.current = error + setStatus(error instanceof PageCaptureError ? error : captureErrorMessage(error)) } finally { if (generation === captureGenerationRef.current) { busyRef.current = false @@ -102,6 +162,23 @@ function SidePanel({ origin }: SidePanelProps) { useEffect(() => { let disposed = false + const currentWindow = chrome.windows.getCurrent() + const manualIntents = new Set() + let processingManual = false + const reference = createReferenceCapture({ + session: () => sessionRef.current, + post: (message) => frameRef.current?.contentWindow?.postMessage(message, SIM_ORIGIN), + manualCaptureError: () => { + if (busyRef.current || pendingRef.current || processingManual || manualIntents.size) + return { + message: 'A page attachment is still being prepared. Try again once it appears.', + } + if (unreadableSelection.current) return { message: BLOCKED_SELECTION, reason: 'selection' } + }, + showStatus: setStatus, + clearAccessStatus: () => setStatus((status) => (typeof status === 'string' ? status : '')), + }) + referenceRef.current = reference let appTheme: 'light' | 'dark' | undefined const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') const applyTheme = () => { @@ -111,36 +188,37 @@ function SidePanel({ origin }: SidePanelProps) { } applyTheme() systemTheme.addEventListener('change', applyTheme) - let metadataGeneration = 0 - const updateSite = async () => { - const generation = ++metadataGeneration - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - let next: { origin: string; allowed: boolean } | null = null - if (tab?.url && !tab.incognito) { - const url = new URL(tab.url) - if (['http:', 'https:'].includes(url.protocol) && url.origin !== SIM_ORIGIN) { - next = { - origin: url.origin, - allowed: await chrome.permissions.contains({ origins: [`${url.origin}/*`] }), - } - } - } - if (!disposed && generation === metadataGeneration) setSite(next) + const clearFocusHint = () => { + if (document.hasFocus()) + setStatus((current) => (current === KEYBOARD_FOCUS_HINT ? '' : current)) } - const update = () => { - void updateSite().catch(() => { - if (!disposed) setSite(null) - }) + const clearBlockedSelection = () => { + unreadableSelection.current = null + setStatus((current) => + current instanceof UnreadableSelectionError || current === BLOCKED_SELECTION ? '' : current + ) + } + const activated = ({ tabId, windowId }: chrome.tabs.OnActivatedInfo) => { + const blocked = unreadableSelection.current + if (blocked?.windowId === windowId && blocked.tabId !== tabId) clearBlockedSelection() + } + const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { + if ( + unreadableSelection.current?.tabId === tabId && + (change.status === 'loading' || change.url) + ) + clearBlockedSelection() } let consumingIntent = false const consumeIntent = async () => { - if (disposed || consumingIntent || busyRef.current || pendingRef.current) return + if (disposed || consumingIntent || reference.busy() || busyRef.current || pendingRef.current) + return consumingIntent = true try { - const window = await chrome.windows.getCurrent() + const window = await currentWindow if (window.id === undefined) return const stored = await chrome.storage.session.get(null) - if (disposed || busyRef.current || pendingRef.current) return + if (disposed || reference.busy() || busyRef.current || pendingRef.current) return const keys = Object.keys(stored).filter((key) => key.startsWith(`${CAPTURE_INTENT_KEY}:${window.id}:`) ) @@ -150,7 +228,9 @@ function SidePanel({ origin }: SidePanelProps) { return intent.success && intent.data.windowId === window.id ? [intent.data] : [] }) .sort((left, right) => right.createdAt - left.createdAt) + processingManual = intents.some((intent) => intent.kind !== 'focus') if (keys.length) await chrome.storage.session.remove(keys) + for (const key of keys) manualIntents.delete(key) const intent = intents[0] if (intent && intent.createdAt <= intentCutoffRef.current) { if (!disposed) @@ -164,10 +244,17 @@ function SidePanel({ origin }: SidePanelProps) { setStatus('The capture expired. Attach the page again.') return } - await capture(intent.kind, intent) + if (intent.kind === 'focus') { + const [tab] = await chrome.tabs.query({ active: true, windowId: intent.windowId }) + if (!disposed && tab?.id === intent.tabId && intent.createdAt > intentCutoffRef.current) { + focusRequested.current = true + deliverFocus() + } + } else await capture(intent.kind, intent) } catch { if (!disposed) setStatus('Could not receive the capture request. Attach the page again.') } finally { + processingManual = false consumingIntent = false } } @@ -175,8 +262,44 @@ function SidePanel({ origin }: SidePanelProps) { changes: Record, area: string ) => { - if (area === 'session' && Object.values(changes).some((change) => change.newValue)) - void consumeIntent() + if (area === 'local' && REFERENCE_PAGE_KEY in changes) { + if (changes[REFERENCE_PAGE_KEY].newValue !== true) { + clearBlockedSelection() + } + reference.preferenceChanged(changes[REFERENCE_PAGE_KEY].newValue) + } + if (area === 'session') + void currentWindow.then((window) => { + if (disposed) return + for (const [key, change] of Object.entries(changes)) { + if (!key.startsWith(`${CAPTURE_INTENT_KEY}:${window.id}:`)) continue + const intent = captureIntentSchema.safeParse(change.newValue) + if (intent.success && intent.data.kind !== 'focus') { + manualIntents.add(key) + reference.manualRequested() + } else manualIntents.delete(key) + } + if (Object.values(changes).some((change) => change.newValue)) void consumeIntent() + }) + } + let handshake = 0 + const connect = async (sessionId: string) => { + const current = ++handshake + if (sessionRef.current && sessionRef.current !== sessionId) invalidateSession() + sessionRef.current = sessionId + connectedRef.current = false + if (!(await reference.refreshPreference(sessionId))) return + if (disposed || handshake !== current || sessionRef.current !== sessionId) return + connectedRef.current = true + const connected: ExtensionToAppMessage = { + channel: 'sim-extension', + version: 1, + type: 'connected', + sessionId, + } + frameRef.current?.contentWindow?.postMessage(connected, SIM_ORIGIN) + deliver() + if (!pendingRef.current) deliverFocus() } const message = (event: MessageEvent) => { if (event.origin !== SIM_ORIGIN || event.source !== frameRef.current?.contentWindow) return @@ -186,27 +309,27 @@ function SidePanel({ origin }: SidePanelProps) { appTheme = parsed.data.theme applyTheme() } else if (parsed.data.type === 'ready') { - if (sessionRef.current && sessionRef.current !== parsed.data.sessionId) invalidateSession() - sessionRef.current = parsed.data.sessionId - const connected: ExtensionToAppMessage = { - channel: 'sim-extension', - version: 1, - type: 'connected', - sessionId: parsed.data.sessionId, - } - frameRef.current?.contentWindow?.postMessage(connected, SIM_ORIGIN) - deliver() + void connect(parsed.data.sessionId) } else if (parsed.data.sessionId !== sessionRef.current) { return + } else if (parsed.data.type === 'unavailable') { + invalidateSession() + } else if (!connectedRef.current) { + return + } else if (parsed.data.type === 'composer-focused') { + setStatus((current) => (current === KEYBOARD_FOCUS_HINT ? '' : current)) + } else if (parsed.data.type === 'set-reference-preference') { + void reference.setPreference(parsed.data.enabled) + } else if (parsed.data.type === 'capture-for-send') { + void reference.capture(parsed.data.sessionId, parsed.data.requestId) } else if (parsed.data.type === 'action') { if (!navigator.userActivation.isActive) return if (parsed.data.action === 'site-access') setAccessOpen(true) else void capture(parsed.data.action === 'capture-page' ? 'page' : 'selection') - } else if (parsed.data.type === 'unavailable') { - invalidateSession() } else if (pendingRef.current?.id === parsed.data.snapshotId) { pendingRef.current = null setStatus('') + deliverFocus() void consumeIntent() } } @@ -216,67 +339,53 @@ function SidePanel({ origin }: SidePanelProps) { Date.now() - Date.parse(pendingRef.current.capturedAt) > CAPTURE_INTENT_TTL_MS ) { pendingRef.current = null + focusRequested.current = false setStatus('The capture expired. Attach the page again.') } void consumeIntent() }, 5_000) - const tabUpdated = ( - _tabId: number, - change: chrome.tabs.OnUpdatedInfo, - tab: chrome.tabs.Tab - ) => { - if (!tab.active || (change.url === undefined && change.status === undefined)) return - update() - } + window.addEventListener('focus', clearFocusHint, true) + chrome.tabs.onActivated.addListener(activated) + chrome.tabs.onUpdated.addListener(navigated) window.addEventListener('message', message) chrome.storage.onChanged.addListener(storageChanged) - chrome.tabs.onActivated.addListener(update) - chrome.tabs.onUpdated.addListener(tabUpdated) - chrome.permissions.onAdded.addListener(update) - chrome.permissions.onRemoved.addListener(update) - update() void consumeIntent() return () => { disposed = true captureGenerationRef.current++ pendingRef.current = null sessionRef.current = null + connectedRef.current = false window.clearInterval(expire) systemTheme.removeEventListener('change', applyTheme) + window.removeEventListener('focus', clearFocusHint, true) + chrome.tabs.onActivated.removeListener(activated) + chrome.tabs.onUpdated.removeListener(navigated) window.removeEventListener('message', message) chrome.storage.onChanged.removeListener(storageChanged) - chrome.tabs.onActivated.removeListener(update) - chrome.tabs.onUpdated.removeListener(tabUpdated) - chrome.permissions.onAdded.removeListener(update) - chrome.permissions.onRemoved.removeListener(update) + reference.reset() + referenceRef.current = null } }, []) - async function toggleSite() { - if (!site) return - const origins = [`${site.origin}/*`] - try { - const changed = site.allowed - ? await chrome.permissions.remove({ origins }) - : await chrome.permissions.request({ origins }) - if (!changed) - setStatus('Site access was not changed. You can still use the Sim toolbar icon.') - else setStatus('') - } catch { - setStatus('Could not change site access. Use Chrome’s extension settings.') - } - } - return (
-

- {status} -

+ {typeof status === 'string' ? status : status.message} + {status instanceof SiteAccessRequiredError && ( + <> + setAccessOpen(true)}> + Request site access + + + + )} +