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..8b0d35cfeca --- /dev/null +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -0,0 +1,38 @@ +--- +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. + +In version 0.1.3 or later, **+ → Browser shortcuts** can recover explicit `sim/handbook` searches on Google, Bing, DuckDuckGo, and Yahoo. Enable your provider and approve the site access shown by Chrome. This leaves ordinary searches and your default search engine unchanged. See [shortcut setup and supported inputs](/search/sim-links#open-links-from-the-address-bar). + +## 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..3520c7c0eac 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -15,15 +15,29 @@ 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`, select **Enable sim/ shortcuts** in that dialog once, using the Chrome profile where Sim is installed. This opens your Sim Links directory in a new tab and lets Chrome remember the short host in that profile. If Chrome still sends a shortcut to search, enter `http://sim/handbook` or `sim/handbook/` with a trailing slash. Use **Copy link** in Sim to share an address that also works without the extension. + +In Sim for Chrome 0.1.3 or later, open the extension sidebar’s **+ → Browser shortcuts** and enable your search provider to recover `sim/handbook` when Chrome treats it as a search. Approve access to the exact sites shown. This is optional and supports Google (`google.com`), Bing, DuckDuckGo, and Yahoo. Sim remembers the choice in this Chrome profile. Ordinary searches still use your search engine; its default is unchanged. + +Recovery matches a whole `sim/` shortcut, including simple slash-separated values such as `sim/jira/123`. For values containing spaces, query parameters, unsupported search domains, or a managed browser that blocks site access, use `sim` + **Space** or the full `https://www.sim.ai/sim/` address. Turn recovery off in **Browser shortcuts**; this keeps the site permission, which you can remove through **Site access** or Chrome’s extension settings. + +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**. Chrome administrators can add the shortcut for everyone with the `SiteSearchSettings` policy. +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. + +### 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 +45,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..28584cfb483 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,18 +30,21 @@ 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. -- 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. +- 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. For these suggestions, 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. +- **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. - 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. - Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation. - Source URLs omit credentials, query parameters, and fragments. Human-readable text can still contain sensitive information; it is the user-selected material they review and send into their conversation. - Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. Extension storage contains only a short-lived intent with a tab/document identity and a SHA-256 URL digest, never raw URLs, captured text, or credentials. Closing a tab or navigating invalidates its pending intent. Sim applies its normal conversation retention once a message is sent. - No browser history, bookmarks, cookie access, debugger access, remote extension JavaScript, arbitrary network proxy, or background content collection is requested. Incognito use is disabled. -- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, or the native site-access dialog. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned dialog. +- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, or the native site-access and browser-shortcut dialogs. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned dialog. Shortcut settings are advertised separately so older installed shells keep working without the new action. - 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..303ae5363be 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); @@ -68,6 +83,14 @@ const fixture = Bun.serve({ ) }, }) +const searchProviders = [ + { id: 'google', hosts: ['www.google.com', 'google.com'], path: '/search', parameter: 'q' }, + { id: 'bing', hosts: ['www.bing.com', 'bing.com'], path: '/search', parameter: 'q' }, + { id: 'duckduckgo', hosts: ['duckduckgo.com', 'www.duckduckgo.com'], path: '/', parameter: 'q' }, + { id: 'yahoo', hosts: ['search.yahoo.com'], path: '/search', parameter: 'p' }, +] as const +const searchRequests: { url: string; method: string }[] = [] +let searchFixture: ReturnType | undefined const temporary = await mkdtemp(resolve(tmpdir(), 'sim-extension-e2e-')) const extensionPath = resolve(temporary, 'extension') let context: BrowserContext | undefined @@ -111,6 +134,50 @@ try { ) assert(!existsSync(rejectedOutput), 'Invalid key material reached build output') }) + const searchKey = resolve(temporary, 'search-key.pem') + const searchCertificate = resolve(temporary, 'search-cert.pem') + const certificate = spawnSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-days', + '1', + '-keyout', + searchKey, + '-out', + searchCertificate, + '-subj', + '/CN=extension-search.test', + ], + { encoding: 'utf8', timeout: 10_000 } + ) + assert(certificate.status === 0, 'Could not create the isolated search fixture certificate') + searchFixture = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + tls: { key: await readFile(searchKey), cert: await readFile(searchCertificate) }, + fetch(request) { + const path = new URL(request.url).pathname + if (path === '/' || path === '/search') + searchRequests.push({ url: request.url, method: request.method }) + return new Response( + 'Local search fixture

Search fixture

', + { + headers: { 'content-type': 'text/html' }, + } + ) + }, + }) + const searchHosts = searchProviders.flatMap((provider) => [...provider.hosts]) + const searchPort = searchFixture.port + const fixtureSearchHosts = [...searchHosts, 'm.google.com', 'www.google.com.evil.test'] + const resolverRules = fixtureSearchHosts + .map((host) => `MAP ${host} 127.0.0.1:${searchPort}`) + .join(', ') const origin = `http://127.0.0.1:${fixture.port}` const pageOrigin = `http://localhost:${fixture.port}` const built = spawnSync(process.execPath, ['scripts/build.ts', `--outdir=${extensionPath}`], { @@ -120,13 +187,23 @@ try { }) assert(built.status === 0, `Extension build failed: ${built.stderr}`) const manifest = JSON.parse(await readFile(resolve(extensionPath, 'manifest.json'), 'utf8')) - // The test package pregrants its disposable fixture site, equivalent to an accepted site prompt. - manifest.host_permissions.push(`${pageOrigin}/*`) + // Pregrants isolate routing from consent; approved search hosts resolve to the local fixture. + manifest.host_permissions.push( + `${pageOrigin}/*`, + ...fixtureSearchHosts.map((host) => `https://${host}/*`) + ) await writeFile(resolve(extensionPath, 'manifest.json'), JSON.stringify(manifest)) context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), { channel: 'chromium', headless: true, - args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`], + ignoreHTTPSErrors: true, + args: [ + `--host-resolver-rules=${resolverRules}`, + '--no-proxy-server', + '--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 +265,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 +322,7 @@ try { }, { targetOrigin: `chrome-extension://${extensionId}`, sessionId: receiverSessionId } ) + await expect(app.locator('body')).toHaveAttribute('data-connected', receiverSessionId) } const pendingIntent = () => worker.evaluate(async () => { @@ -240,11 +360,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()}`) } @@ -253,6 +378,74 @@ try { } ) + await check( + 'Link drafts enforce the saved URL limit after removing private URL parts', + async () => { + const prefix = 'https://links.controls.test/' + const cases = [ + { + name: 'A canonical 2048-character destination remains usable', + input: `${prefix}${'a'.repeat(2048 - prefix.length)}`, + expected: `${prefix}${'a'.repeat(2048 - prefix.length)}`, + }, + { + name: 'A canonical 2049-character destination does not open an unsavable draft', + input: `${prefix}${'a'.repeat(2049 - prefix.length)}`, + expected: null, + }, + { + name: 'URL serialization cannot expand a short Unicode input beyond the saved limit', + input: `${prefix}${'é'.repeat(400)}`, + expected: null, + }, + { + name: 'A removable long private query does not reject a short destination', + input: `${prefix}safe?token=${'s'.repeat(3000)}#private-section`, + expected: `${prefix}safe`, + }, + ] + const failures: string[] = [] + for (const entry of cases) { + await page.bringToFront() + const opened = await worker.evaluate(async (linkUrl) => { + const before = await chrome.tabs.query({}) + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (!tab) throw new Error('Missing context-menu source tab') + const clicked = chrome.contextMenus.onClicked as typeof chrome.contextMenus.onClicked & { + dispatch: (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => void + } + clicked.dispatch({ menuItemId: 'create-sim-link', linkUrl, editable: false }, tab) + const created = (await chrome.tabs.query({})).filter( + (candidate) => !before.some((existing) => existing.id === candidate.id) + ) + const urls = created.map((candidate) => candidate.pendingUrl || candidate.url || '') + for (const candidate of created) { + if (candidate.id !== undefined) await chrome.tabs.remove(candidate.id) + } + return urls + }, entry.input) + if (entry.expected === null) { + if (opened.length !== 0) failures.push(`${entry.name}: opened ${opened.length} draft`) + continue + } + if (opened.length !== 1) { + failures.push(`${entry.name}: opened ${opened.length} drafts`) + continue + } + const draft = new URL(opened[0]) + if ( + draft.origin !== origin || + draft.pathname !== '/extension/create-link' || + draft.search !== '' || + new URLSearchParams(draft.hash.slice(1)).get('url') !== entry.expected + ) + failures.push(`${entry.name}: incorrect private-safe destination handoff`) + } + assert(failures.length === 0, failures.join('\n')) + await page.bringToFront() + } + ) + await check('Capture stays local until the trusted hosted frame is ready', async () => { await capture() await panel.getByRole('status').filter({ hasText: 'Waiting for Sim' }).waitFor() @@ -399,6 +592,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 +833,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 +892,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 +933,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 +1066,429 @@ 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( + 'Stale capture intents cannot interrupt an explicit send-time reference', + async () => { + await page.goto(`${pageOrigin}/stale-intent-source`) + await page.bringToFront() + for (const stale of ['expired', 'previous-session', 'other-window', 'future'] as const) { + const intent = await pendingIntent() + const sourceWindowId = intent.windowId + if (stale === 'expired') intent.createdAt -= 31_000 + else if (stale === 'future') intent.createdAt += 60_000 + else if (stale === 'other-window') intent.windowId++ + else { + receiverSessionId = generateId() + await ready() + } + await setReference(true) + const before = (await snapshots()).length + const key = `captureIntent:${sourceWindowId}:${stale}` + await panel.evaluate((key) => { + const original = chrome.scripting.executeScript.bind(chrome.scripting) + document.body.dataset.extraction = 'pending' + delete document.body.dataset.staleIntent + const changed = (changes: Record, area: string) => { + if (area !== 'session' || !changes[key]?.newValue) return + chrome.storage.onChanged.removeListener(changed) + document.body.dataset.staleIntent = key + } + chrome.storage.onChanged.addListener(changed) + 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 + }, key) + const pending = reference() + try { + await expect(panel.locator('body')).toHaveAttribute('data-extraction', 'complete') + await worker.evaluate( + ({ key, intent }) => chrome.storage.session.set({ [key]: intent }), + { + key, + intent, + } + ) + await expect(panel.locator('body')).toHaveAttribute('data-stale-intent', key) + } finally { + await panel.evaluate(() => document.dispatchEvent(new Event('release-extraction'))) + } + const reply = await pending + assert( + reply.type === 'send-context' && reply.snapshot.text.includes('visible-reference-482'), + `${stale} intent cancelled the current reference: ${reply.type}` + ) + await expect + .poll(() => + worker.evaluate(async (key) => !(key in (await chrome.storage.session.get(key))), key) + ) + .toBe(true) + await settleMessages() + assert((await snapshots()).length === before, `${stale} intent attached a manual page`) + 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 check( + 'Opted-in shortcut recovery redirects only explicit searches on the chosen providers', + async () => { + const tab = await page.context().newPage() + const toggle = async (provider: string, enabled: boolean) => { + const result = await panel.evaluate( + ({ provider, enabled }) => + chrome.runtime.sendMessage({ type: 'set-search-recovery', provider, enabled }), + { provider, enabled } + ) + assert( + result?.ok === true, + `Could not ${enabled ? 'enable' : 'disable'} ${provider} recovery` + ) + } + const unchanged = async (url: string) => { + await tab.goto(url) + assert(tab.url() === url, `An ordinary or ambiguous search was redirected: ${url}`) + assert( + (await tab.title()) === 'Local search fixture', + 'A provider request escaped the local fixture' + ) + } + try { + for (const provider of searchProviders) { + const endpoint = `https://${provider.hosts[0]}${provider.path}` + const url = `${endpoint}?${provider.parameter}=sim%2Faskit` + await unchanged(url) + await toggle(provider.id, true) + for (const host of provider.hosts) { + for (const shortcut of ['sim%2Faskit', 'sim/jira/ABC-123']) { + const candidate = `https://${host}${provider.path}?client=chrome&${provider.parameter}=${shortcut}&source=qa` + const prior = searchRequests.filter((request) => request.url === candidate).length + await tab.goto(candidate) + assert( + tab.url() === `${origin}/sim-links/recover?q=${shortcut}`, + `${provider.id} lost the explicit shortcut` + ) + assert( + searchRequests.filter((request) => request.url === candidate).length === prior, + 'The matched shortcut reached the search provider' + ) + } + } + for (const query of [ + `${provider.parameter}=quarterly+planning`, + `${provider.parameter}=sim%2Faskit%20help`, + `${provider.parameter}=sim%2Faskit+help`, + `${provider.parameter}=sim%252Faskit`, + `${provider.parameter}=sim%2Faskit%ZZ`, + `${provider.parameter}=sim%2Faskit&${provider.parameter}=other`, + `${provider.parameter}=other&${provider.parameter}=sim%2Faskit`, + `${provider.parameter}=sim%2Faskit&%${provider.parameter.charCodeAt(0).toString(16)}=other`, + ]) + await unchanged(`${endpoint}?${query}`) + await toggle(provider.id, false) + await unchanged(url) + assert( + await worker.evaluate( + (origins) => chrome.permissions.contains({ origins }), + provider.hosts.map((host) => `https://${host}/*`) + ), + 'Disabling recovery removed a capture permission' + ) + } + await toggle('google', true) + for (const url of [ + 'https://www.google.com/other?q=sim%2Faskit', + 'https://m.google.com/search?q=sim%2Faskit', + 'https://www.google.com.evil.test/search?q=sim%2Faskit', + 'https://www.google.com/search?%71=sim%2Faskit', + ]) + await unchanged(url) + const search = 'https://www.google.com/search?q=sim%2Faskit' + await tab.goto(`${origin}/post-fixture`) + await Promise.all([ + tab.waitForNavigation(), + tab.evaluate((url) => { + const form = document.createElement('form') + form.method = 'POST' + form.action = url + document.body.append(form) + form.submit() + }, search), + ]) + assert( + tab.url() === search && + searchRequests.some((request) => request.url === search && request.method === 'POST'), + 'A POST search was recovered' + ) + await tab.goto(`${origin}/frame-fixture`) + await Promise.all([ + tab.waitForEvent( + 'framenavigated', + (child) => child.parentFrame() !== null && child.url() === search + ), + tab.evaluate((url) => { + const child = document.createElement('iframe') + child.src = url + document.body.append(child) + }, search), + ]) + assert( + tab.url() === `${origin}/frame-fixture`, + 'A subframe search changed the top-level page' + ) + await toggle('google', false) + } finally { + await tab.close() + } + } + ) await mkdir(dirname(output), { recursive: true }) await panel.screenshot({ path: output.replace(/\.json$/, '.png'), fullPage: true }) } catch (error) { @@ -837,6 +1503,7 @@ try { } finally { await context?.close() fixture.stop(true) + searchFixture?.stop(true) await mkdir(dirname(output), { recursive: true }) await writeFile(output, JSON.stringify({ suite: 'browser-extension', checks }, null, 2)) await rm(temporary, { recursive: true, force: true }) diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index c6585bc53dc..81f6ceb3c7f 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -1,15 +1,25 @@ +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 { registerSearchRecovery } from '@/search-recovery' 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 +27,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 +48,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, @@ -52,15 +92,27 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') { } registerSimLinkOmnibox() +registerSearchRecovery() chrome.runtime.onInstalled.addListener(() => { void configure().catch(() => logger.warn('Could not initialize extension controls')) }) 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..0a91b12070f 100644 --- a/apps/extension/src/intent.ts +++ b/apps/extension/src/intent.ts @@ -3,8 +3,11 @@ 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}$/), }) + +export type CaptureIntent = z.infer 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/search-recovery-dialog.tsx b/apps/extension/src/search-recovery-dialog.tsx new file mode 100644 index 00000000000..bc89f7e943b --- /dev/null +++ b/apps/extension/src/search-recovery-dialog.tsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Chip, + ChipModalBody, + ChipModalDescription, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + ChipModalSurface, + Switch, +} from '@sim/emcn' +import { + readSearchRecovery, + SEARCH_RECOVERY_KEY, + SEARCH_RECOVERY_PROVIDERS, + setSearchRecovery, +} from '@/search-recovery' + +interface SearchRecoveryDialogProps { + onClose: () => void +} + +type SearchProvider = (typeof SEARCH_RECOVERY_PROVIDERS)[number] +type SearchRecovery = Awaited> + +/** Chrome owns modality so the dialog works without an injected scroll-lock stylesheet. */ +function mountSearchRecoveryDialog(dialog: HTMLDialogElement | null) { + if (!dialog) return + dialog.showModal() + return () => dialog.close() +} + +export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { + const generation = useRef(0) + const [settings, setSettings] = useState() + const [busyProvider, setBusyProvider] = useState(null) + const [notices, setNotices] = useState>>({}) + const [loadError, setLoadError] = useState(false) + + const refresh = useCallback(async () => { + const current = ++generation.current + try { + const next = await readSearchRecovery() + if (current === generation.current) { + setSettings(next) + setLoadError(false) + } + } catch { + if (current === generation.current) setLoadError(true) + } + }, []) + + useEffect(() => { + const update = () => void refresh() + const stored = (changes: Record, area: string) => { + if (area === 'local' && SEARCH_RECOVERY_KEY in changes) update() + } + update() + chrome.permissions.onAdded.addListener(update) + chrome.permissions.onRemoved.addListener(update) + chrome.storage.onChanged.addListener(stored) + return () => { + generation.current++ + chrome.permissions.onAdded.removeListener(update) + chrome.permissions.onRemoved.removeListener(update) + chrome.storage.onChanged.removeListener(stored) + } + }, [refresh]) + + async function changeProvider(provider: SearchProvider, enabled: boolean) { + if (!settings || busyProvider || loadError) return + setBusyProvider(provider.id) + setNotices((previous) => ({ ...previous, [provider.id]: '' })) + try { + if (enabled && !(await chrome.permissions.request({ origins: [...provider.origins] }))) { + setNotices((previous) => ({ + ...previous, + [provider.id]: 'Site access was not granted. This shortcut is still off.', + })) + return + } + await setSearchRecovery(provider.id, enabled) + await refresh() + } catch { + setNotices((previous) => ({ + ...previous, + [provider.id]: 'Could not change this shortcut. Try again.', + })) + await refresh() + } finally { + setBusyProvider(null) + } + } + + return ( + { + event.preventDefault() + onClose() + }} + onClick={(event) => { + if (event.target === event.currentTarget) onClose() + }} + > + + + Browser shortcuts + + + + Send explicit sim/ searches to your Sim Links. Ordinary searches stay with your search + engine. + + {SEARCH_RECOVERY_PROVIDERS.map((provider) => ( + void changeProvider(provider, enabled)} + /> + } + hint={provider.origins.map((origin) => new URL(origin).origin).join(', ')} + submitOnEnter={false} + > + {notices[provider.id] && ( +

+ {notices[provider.id]} +

+ )} +
+ ))} +

+ Chrome asks for access to the sites shown above. Page reference stays unchanged. Turning + a shortcut off keeps site access. +

+ {loadError && ( +
+

+ Could not load browser shortcuts. +

+ void refresh()}>Retry +
+ )} +
+ +
+
+ ) +} diff --git a/apps/extension/src/search-recovery.ts b/apps/extension/src/search-recovery.ts new file mode 100644 index 00000000000..f89356ad0a1 --- /dev/null +++ b/apps/extension/src/search-recovery.ts @@ -0,0 +1,198 @@ +import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' +import { escapeRegExp } from '@sim/utils/string' +import { SIM_ORIGIN } from '@/config' + +export const SEARCH_RECOVERY_KEY = 'searchRecovery' +export const SEARCH_RECOVERY_PROVIDERS = [ + { + id: 'google', + label: 'Google', + origins: ['https://www.google.com/*', 'https://google.com/*'], + path: '/search', + parameter: 'q', + ruleId: 101, + }, + { + id: 'bing', + label: 'Bing', + origins: ['https://www.bing.com/*', 'https://bing.com/*'], + path: '/search', + parameter: 'q', + ruleId: 102, + }, + { + id: 'duckduckgo', + label: 'DuckDuckGo', + origins: ['https://duckduckgo.com/*', 'https://www.duckduckgo.com/*'], + path: '/', + parameter: 'q', + ruleId: 103, + }, + { + id: 'yahoo', + label: 'Yahoo', + origins: ['https://search.yahoo.com/*'], + path: '/search', + parameter: 'p', + ruleId: 104, + }, +] as const + +type ProviderId = (typeof SEARCH_RECOVERY_PROVIDERS)[number]['id'] +type RecoveryPreferences = Record +const logger = createLogger('BrowserShortcutRecovery') +const MESSAGE_TYPE = 'set-search-recovery' + +/** Search text is never observed in JavaScript; Chrome matches only explicit shortcut requests. */ +function recoveryRules( + provider: (typeof SEARCH_RECOVERY_PROVIDERS)[number] +): chrome.declarativeNetRequest.Rule[] { + const hosts = provider.origins.map((origin) => escapeRegExp(new URL(origin).host)).join('|') + const character = '(?:[a-z0-9._~:@/-]|%(?:2[6def]|3[0-9a]|4[0-9a-f]|5[0-9af]|6[1-9a-f]|7[0-9ae]))' + const shortcut = `sim(?:/|%2f)${character}+` + const condition = { + isUrlFilterCaseSensitive: false, + requestDomains: provider.origins.map((origin) => new URL(origin).hostname), + resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], + requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET], + } + // Separate ambiguity guards keep the redirect below Chrome's compiled-regex memory limit. + return [ + { + id: provider.ruleId + 100, + priority: 2, + action: { type: chrome.declarativeNetRequest.RuleActionType.ALLOW }, + condition: { + ...condition, + regexFilter: `[?&]${provider.parameter}=.*&${provider.parameter}=`, + }, + }, + { + id: provider.ruleId + 200, + priority: 2, + action: { type: chrome.declarativeNetRequest.RuleActionType.ALLOW }, + condition: { ...condition, regexFilter: '[?&][^=&]*%[^=&]*=' }, + }, + { + id: provider.ruleId, + priority: 1, + action: { + type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, + redirect: { regexSubstitution: `${SIM_ORIGIN}/sim-links/recover?q=\\1` }, + }, + condition: { + ...condition, + regexFilter: `^https://(?:${hosts})${escapeRegExp(provider.path)}\\?(?:[^#]*&)?${provider.parameter}=(${shortcut})(?:&[^#]*)?$`, + }, + }, + ] +} + +/** A remembered choice is effective only while Chrome still grants that provider's sites. */ +export async function readSearchRecovery(): Promise { + const stored = (await chrome.storage.local.get(SEARCH_RECOVERY_KEY))[SEARCH_RECOVERY_KEY] + const preferences: RecoveryPreferences = { + google: false, + bing: false, + duckduckgo: false, + yahoo: false, + } + for (const provider of SEARCH_RECOVERY_PROVIDERS) { + preferences[provider.id] = + isRecordLike(stored) && + stored[provider.id] === true && + (await chrome.permissions.contains({ origins: [...provider.origins] })) + } + return preferences +} + +/** The worker serializes settings and rule updates across every open side panel. */ +export async function setSearchRecovery(provider: ProviderId, enabled: boolean): Promise { + const response: unknown = await chrome.runtime.sendMessage({ + type: MESSAGE_TYPE, + provider, + enabled, + }) + if (!isRecordLike(response) || response.ok !== true) + throw new Error('Could not update browser shortcuts.') +} + +/** Reconcile persisted, permission-bound rules at startup, on revocation, and after an explicit choice. */ +export function registerSearchRecovery() { + let pending = Promise.resolve() + const update = (choice?: { provider: ProviderId; enabled: boolean }) => { + const operation = pending.then(async () => { + const preferences = await readSearchRecovery() + if (choice) { + const provider = SEARCH_RECOVERY_PROVIDERS.find( + (provider) => provider.id === choice.provider + ) + if (!provider) throw new Error('Unknown shortcut provider.') + if ( + choice.enabled && + !(await chrome.permissions.contains({ origins: [...provider.origins] })) + ) + throw new Error('Search site access has not been granted.') + preferences[choice.provider] = choice.enabled + } + const rules = SEARCH_RECOVERY_PROVIDERS.filter( + (provider) => preferences[provider.id] + ).flatMap(recoveryRules) + for (const rule of rules) { + const supported = await chrome.declarativeNetRequest.isRegexSupported({ + regex: rule.condition.regexFilter ?? '', + isCaseSensitive: false, + requireCapturing: true, + }) + if (!supported.isSupported) throw new Error('Chrome cannot apply shortcut recovery.') + } + const ruleIds = SEARCH_RECOVERY_PROVIDERS.flatMap((provider) => [ + provider.ruleId, + provider.ruleId + 100, + provider.ruleId + 200, + ]) + const previousRules = (await chrome.declarativeNetRequest.getDynamicRules()).filter((rule) => + ruleIds.includes(rule.id) + ) + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: ruleIds, + addRules: rules, + }) + try { + await chrome.storage.local.set({ [SEARCH_RECOVERY_KEY]: preferences }) + } catch (error) { + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: ruleIds, + addRules: previousRules, + }) + throw error + } + }) + pending = operation.catch(() => logger.warn('Could not update browser shortcut recovery')) + return operation + } + const reconcile = () => { + void update().catch(() => {}) + } + chrome.runtime.onInstalled.addListener(reconcile) + chrome.runtime.onStartup.addListener(reconcile) + chrome.permissions.onRemoved.addListener(reconcile) + chrome.runtime.onMessage.addListener((message: unknown, sender, respond) => { + if ( + sender.id !== chrome.runtime.id || + !sender.url?.startsWith(chrome.runtime.getURL('')) || + !isRecordLike(message) || + message.type !== MESSAGE_TYPE || + typeof message.enabled !== 'boolean' + ) + return + const provider = SEARCH_RECOVERY_PROVIDERS.find((provider) => provider.id === message.provider) + if (!provider) return + void update({ provider: provider.id, enabled: message.enabled }).then( + () => respond({ ok: true }), + () => respond({ ok: false }) + ) + return true + }) +} 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..dc74d3d56f8 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -4,43 +4,78 @@ 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 { captureIntentSchema } from '@/intent' +import { + captureActivePage, + captureErrorMessage, + PageCaptureError, + SiteAccessRequiredError, + UnreadableSelectionError, +} from '@/capture' +import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config' +import { type CaptureIntent, captureIntentSchema } from '@/intent' +import { createReferenceCapture } from '@/reference' +import { SearchRecoveryDialog } from '@/search-recovery-dialog' +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 [shortcutsOpen, setShortcutsOpen] = useState(false) + 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,22 +90,52 @@ 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) + setShortcutsOpen(false) if (pendingRef.current || busyRef.current) setStatus('The conversation changed before delivery. Attach the page again.') captureGenerationRef.current++ 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 +147,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 +165,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 +191,46 @@ 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 currentIntent = (intent: CaptureIntent, windowId: number | undefined) => { + const age = Date.now() - intent.createdAt + return ( + intent.windowId === windowId && + intent.createdAt > intentCutoffRef.current && + age >= 0 && + age <= CAPTURE_INTENT_TTL_MS + ) + } 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 +240,11 @@ 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' && currentIntent(intent, window.id) + ) 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) @@ -160,14 +254,21 @@ function SidePanel({ origin }: SidePanelProps) { return } if (disposed || !intent) return - if (Date.now() - intent.createdAt > CAPTURE_INTENT_TTL_MS) { + if (!currentIntent(intent, window.id)) { 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 +276,55 @@ 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' && + currentIntent(intent.data, window.id) + ) { + 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) + const shortcuts: ExtensionToAppMessage = { + channel: 'sim-extension', + version: 1, + type: 'shortcuts-available', + sessionId, + } + frameRef.current?.contentWindow?.postMessage(shortcuts, SIM_ORIGIN) + deliver() + if (!pendingRef.current) deliverFocus() } const message = (event: MessageEvent) => { if (event.origin !== SIM_ORIGIN || event.source !== frameRef.current?.contentWindow) return @@ -186,27 +334,28 @@ 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 if (parsed.data.action === 'browser-shortcuts') setShortcutsOpen(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 +365,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 + + + + )} +