diff --git a/.github/CI.md b/.github/CI.md index 15b06c5be45..1831aefbb53 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -38,6 +38,8 @@ Memory-heavy builds and browser jobs retain appropriately sized Blacksmith runne WebKit acceptance on Blacksmith uses the matching, digest-pinned Playwright browser image for mobile and docs navigation. The app, database fixtures, native modules, and harness stay on the host and retain loopback URLs. Chromium and GitHub fallback keep native browser installation and launch. Update both workspace Playwright dependencies, the image index digest, and the expected client version/revision checks together; verify the installed release's native launch timeout and real WebKit acceptance before changing the pin. +HTTP and docs browser installation first asks the pinned Playwright driver to simulate its OS dependency installation. When the simulation requires no package changes, the runner avoids an apt update; missing dependencies, proposed upgrades, or an unsuccessful simulation use the normal dependency installer. Browser selection and downloads still use the pinned driver, including full native Chromium for extension acceptance. + Every HTTP and browser group consumes the same production build in an isolated job/database. Electron also uses that build and skips its development-route warm-up. Local HTTP and Electron development runs retain their dev-server behavior. Build inputs exclude test-only files so a harness edit can reuse an unchanged production build. Desktop inbox protocol, assistant actions UI, and organization search UI run as three parallel HTTP jobs with isolated databases, apps, and reports. Each harness owns its acceptance task, so an edit selects its own job. The inbox protocol task depends on server and realtime code; the browser tasks also depend on workspace UI. Desktop inbox needs no browser. The UI jobs and browser extension install Chromium while their app starts, waiting for both before executing assertions; browser-install failures still fail the job and preserve the install log. No acceptance assertions are removed by the split. @@ -71,6 +73,8 @@ The current weights are median test durations in seconds from runs [41496623284] Desktop native outputs are keyed by source, build recipes, compiler, SDK, OS, architecture, and Node header bytes. Every build still validates restored checksums, executable modes, universal architectures, and the helper signature; invalid or incomplete outputs rebuild. Native-input pushes to main/staging seed trusted Node 22 and 24 caches without repeating the PR smoke suite. PR cache entries never populate trusted branch caches. +Desktop releases resolve and validate their source once, then check out that immutable commit for packaging and four isolated smoke partitions. The release smoke partitions reuse the existing whole-file duration sharder on the release's Node 22 toolchain and run alongside signing and notarization. Historical tags without the sharder retain one complete smoke run, and native compilation caching is optional when the checked-out revision predates it. Packaging retains source checks, package validation, and packaged Electron smoke. Publication waits for packaging and every smoke partition, downloads the packaging job's immutable artifact ID from the same run, verifies any existing release assets, and uploads the updater manifest last. Artifact-only runs never publish. + Each shard uploads its coverage/timing report, the complete discovery report, and its deterministic shard plan. `bun run test:scripts scripts/duration-shards.test.ts scripts/desktop-e2e.test.ts` checks partition coverage, unknown files, deterministic ordering, and balancing. The CLI regression executes tiny browser-free Playwright fixtures to prove that imported test factories and paths containing regex characters are selected correctly; it launches no Electron or browser. Local pushes run `lint:check` and `type-check` through Husky before Git sends commits. These gates reuse Turborepo's task cache; a failure stops the push. The pre-commit hook still formats staged files, and hosted CI owns tests, audits, builds, and acceptance checks. diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index e05cfbf3d09..8c5f185e7cd 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -75,7 +75,7 @@ start_browser_install() { local name=$1 shift browsers_log="$report_dir/$name-playwright-install.log" - setsid --wait bunx --no-install playwright install --with-deps "$@" > "$browsers_log" 2>&1 & + setsid --wait bash "$GITHUB_WORKSPACE/.github/scripts/install-playwright.sh" "$@" > "$browsers_log" 2>&1 & browsers_pid=$! } @@ -279,6 +279,7 @@ case "$group" in export REDIS_URL=redis://127.0.0.1:6379 export NEXT_PUBLIC_FORCE_HOSTED=false export KNOWLEDGE_MEMBER_ACCESS=true CREDENTIAL_GROUPS=true ACCESS_CONTROL_ENABLED=true + export SIM_LINKS=true export NEXT_PUBLIC_ACCESS_CONTROL_ENABLED=true export API_ENCRYPTION_KEY="$ENCRYPTION_KEY" start_browser_install "$group" --no-shell chromium diff --git a/.github/scripts/install-playwright.sh b/.github/scripts/install-playwright.sh new file mode 100644 index 00000000000..c9065602919 --- /dev/null +++ b/.github/scripts/install-playwright.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# The dependency dry run uses the pinned driver's package list without updating apt. +set -euo pipefail + +browsers=() +for argument in "$@"; do + case "$argument" in + --only-shell|--no-shell) ;; + *) browsers+=("$argument") ;; + esac +done + +if ! bunx --no-install playwright install-deps --dry-run "${browsers[@]}"; then + bunx --no-install playwright install-deps "${browsers[@]}" +fi +exec bunx --no-install playwright install "$@" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f8d7da153c0..836f2a2644d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -57,20 +57,24 @@ on: default: true permissions: - contents: write + contents: read jobs: - build: - name: build - runs-on: macos-26 - timeout-minutes: 60 + source: + name: source + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + name: ${{ steps.channel.outputs.name }} + app_id: ${{ steps.channel.outputs.app_id }} + origin: ${{ steps.channel.outputs.origin }} + release_repository: ${{ steps.channel.outputs.release_repository }} + token_kind: ${{ steps.channel.outputs.token_kind }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: - fetch-depth: 0 persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.sha }} # Prerelease versions carry their environment in the tag: -dev.N is a # dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain @@ -115,11 +119,16 @@ jobs: echo "::error::Manual desktop releases must use a stable source-repository tag." exit 1 fi + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$GITHUB_REF" != "refs/tags/$VERSION" ]; then + echo "::error::Run this workflow from the $VERSION tag, so it executes only that tag's code and its caches stay scoped to that tag." + exit 1 + fi if [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then echo "::error::Desktop releases must be signed before publication." exit 1 fi if [ "$TOKEN_KIND" = stable ]; then + git fetch --depth=1 origin "refs/tags/${VERSION}:refs/tags/${VERSION}" TAG_COMMIT="$(git rev-parse "refs/tags/${VERSION}^{commit}")" HEAD_COMMIT="$(git rev-parse HEAD)" if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then @@ -140,28 +149,49 @@ jobs: exit 1 fi + smoke: + name: smoke (${{ matrix.shard }}/${{ strategy.job-total }}) + needs: source + runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - &checkout-source + name: Checkout verified source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.4 - - name: Setup Node + - &setup-node + name: Setup Node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: 22 - - name: Cache Electron binaries + - name: Cache Electron and Playwright downloads uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 with: path: | ~/Library/Caches/electron ~/Library/Caches/electron-builder - key: electron-cache-${{ runner.os }}-${{ hashFiles('apps/desktop/package.json') }} + ~/Library/Caches/ms-playwright + key: desktop-downloads-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: desktop-downloads-${{ runner.os }}- - - name: Install dependencies + - &install-dependencies + name: Install dependencies run: bun install --frozen-lockfile - - name: Inject release version + - &inject-version + name: Inject release version env: VERSION: ${{ inputs.version }} run: | @@ -175,27 +205,83 @@ jobs: exit 1 fi - - name: Verify desktop source - run: | - bun run --cwd apps/desktop lint:check - bun run --cwd apps/desktop type-check - bun run --cwd apps/desktop test + - name: Restore native compilation + uses: ./.github/actions/desktop-native-cache - - name: Bundle main and preload + - &bundle-desktop + name: Bundle main and preload working-directory: apps/desktop env: - SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} + SIM_DESKTOP_DEFAULT_ORIGIN: ${{ needs.source.outputs.origin }} run: bun run build - name: Install system-browser fixture working-directory: apps/desktop run: bunx playwright install chromium - - name: Run Electron smoke tests + - name: Install tmux + run: brew install tmux + + - name: Run Electron smoke partition working-directory: apps/desktop env: - SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} - run: bun run test:e2e + SIM_DESKTOP_DEFAULT_ORIGIN: ${{ needs.source.outputs.origin }} + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/desktop-smoke.json + BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json + TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json + CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json + run: bun run ../../scripts/desktop-e2e.ts ${{ matrix.shard }}/${{ strategy.job-total }} + + - name: Upload smoke coverage and timings + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: desktop-release-smoke-${{ matrix.shard }} + path: | + apps/desktop/test-results/*.json + if-no-files-found: ignore + retention-days: 7 + + - name: Upload Electron failure artifacts + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: desktop-release-smoke-failure-${{ matrix.shard }} + path: apps/desktop/test-results/ + if-no-files-found: ignore + retention-days: 7 + + # Restores no caches: every byte that gets signed comes from this checkout or + # a fresh download, never from a cache another run could have written. + build: + name: build + needs: source + outputs: + artifact_id: ${{ steps.artifacts.outputs.artifact-id }} + runs-on: macos-26 + timeout-minutes: 60 + steps: + - *checkout-source + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.4 + no-cache: true + + - *setup-node + + - *install-dependencies + + - *inject-version + + - name: Verify desktop source + run: | + bun run --cwd apps/desktop lint:check + bun run --cwd apps/desktop type-check + bun run --cwd apps/desktop test + + - *bundle-desktop - name: Write App Store Connect API key if: ${{ inputs.sign }} @@ -253,8 +339,8 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - PRODUCT_NAME: ${{ steps.channel.outputs.name }} - APP_ID: ${{ steps.channel.outputs.app_id }} + PRODUCT_NAME: ${{ needs.source.outputs.name }} + APP_ID: ${{ needs.source.outputs.app_id }} run: > bunx electron-builder --mac --publish never -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" @@ -274,8 +360,8 @@ jobs: working-directory: apps/desktop env: CSC_IDENTITY_AUTO_DISCOVERY: 'false' - PRODUCT_NAME: ${{ steps.channel.outputs.name }} - APP_ID: ${{ steps.channel.outputs.app_id }} + PRODUCT_NAME: ${{ needs.source.outputs.name }} + APP_ID: ${{ needs.source.outputs.app_id }} run: > bunx electron-builder --mac --publish never -c.mac.notarize=false -c.mac.identity=- -c.mac.hardenedRuntime=false @@ -333,9 +419,9 @@ jobs: - name: Validate signature and notarization if: ${{ inputs.sign }} env: - APP_ID: ${{ steps.channel.outputs.app_id }} + APP_ID: ${{ needs.source.outputs.app_id }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - PRODUCT_NAME: ${{ steps.channel.outputs.name }} + PRODUCT_NAME: ${{ needs.source.outputs.name }} VERSION: ${{ inputs.version }} run: | SEMVER="${VERSION#v}" @@ -403,13 +489,40 @@ jobs: fi SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts + - name: Upload artifacts to the workflow run + id: artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: sim-desktop-${{ inputs.version }} + compression-level: 0 + path: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.blockmap + apps/desktop/release/latest-mac.yml + retention-days: 7 + + publish: + name: publish + needs: [source, build, smoke] + if: inputs.publish + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download verified desktop artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build.outputs.artifact_id }} + path: apps/desktop/release + - name: Upload artifacts to the release - if: ${{ inputs.publish }} env: DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} - RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }} + RELEASE_REPOSITORY: ${{ needs.source.outputs.release_repository }} SOURCE_RELEASE_TOKEN: ${{ github.token }} - TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + TOKEN_KIND: ${{ needs.source.outputs.token_kind }} VERSION: ${{ inputs.version }} run: | case "$TOKEN_KIND" in @@ -448,7 +561,7 @@ jobs: local ARTIFACT="$1" local NAME SIZE DIGEST REMOTE REMOTE_SIZE REMOTE_DIGEST NAME="$(basename "$ARTIFACT")" - SIZE="$(stat -f%z "$ARTIFACT")" + SIZE="$(stat -c%s "$ARTIFACT")" DIGEST="sha256:$(shasum -a 256 "$ARTIFACT" | awk '{print $1}')" REMOTE="$(jq -c --arg name "$NAME" '.assets[] | select(.name == $name)' <<< "$RELEASE_JSON")" if [ -n "$REMOTE" ]; then @@ -467,15 +580,3 @@ jobs: upload_or_verify "$ARTIFACT" done upload_or_verify "${ARTIFACTS[4]}" - - - name: Upload artifacts to the workflow run - if: ${{ !inputs.publish }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: sim-desktop-${{ inputs.version }} - path: | - apps/desktop/release/*.dmg - apps/desktop/release/*.zip - apps/desktop/release/*.blockmap - apps/desktop/release/latest-mac.yml - retention-days: 7 diff --git a/.github/workflows/docs-negotiation.yml b/.github/workflows/docs-negotiation.yml index 0fcdf596276..3cce1c8c815 100644 --- a/.github/workflows/docs-negotiation.yml +++ b/.github/workflows/docs-negotiation.yml @@ -20,6 +20,7 @@ on: - '.github/actions/setup/**' - '.github/scripts/stop-session.sh' - '.github/scripts/playwright-server.sh' + - '.github/scripts/install-playwright.sh' push: branches: [staging] paths: @@ -40,6 +41,7 @@ on: - '.github/actions/setup/**' - '.github/scripts/stop-session.sh' - '.github/scripts/playwright-server.sh' + - '.github/scripts/install-playwright.sh' workflow_dispatch: inputs: benchmark: @@ -375,7 +377,7 @@ jobs: if: matrix.group == 'chromium' || (matrix.group == 'webkit' && vars.CI_PROVIDER != '' && vars.CI_PROVIDER != 'blacksmith') env: BROWSER: ${{ matrix.group }} - run: bunx --no-install playwright install --with-deps --only-shell "$BROWSER" + run: bash .github/scripts/install-playwright.sh --only-shell "$BROWSER" - name: Verify production documentation id: acceptance env: diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ce6f3212a71..3b33249a679 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -108,7 +108,8 @@ The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every cha CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`): - Stable builds run only after `release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never`; reruns verify the size and SHA-256 digest of existing release assets instead of overwriting them. -- **Secrets gate**: `desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "desktop-release" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release). +- **Secrets gate**: `desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "desktop-release" → Run workflow, select the `vX.Y.Z` tag under "Use workflow from", and enter the same version (`publish: false` uploads artifacts to the run instead of the release). Any other ref is refused, so a run only executes the tag's own code and workflow, and its caches stay scoped to that tag. +- **Cache trust**: the signed `build` job restores no caches, so nothing it signs can come from a cache another run wrote. Only the unsigned smoke jobs use caches. - The product semver is **injected** from the release tag into `apps/desktop/package.json` at build time (repo package versions are placeholders). A mismatch guard fails the build. - Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, and cookie encryption on. The packaged smoke test asserts every fuse byte so Electron upgrades fail until new fuses receive an explicit policy. - **Cookie-encryption go/no-go**: on every Electron bump, verify a packaged build keeps its session across relaunch (there are historical cookie-persistence bugs with the `EnableCookieEncryption` fuse). If it reproduces, set `enableCookieEncryption: false` and record it here. diff --git a/apps/desktop/docs/electron-upgrade-checklist.md b/apps/desktop/docs/electron-upgrade-checklist.md index dbdd667bddf..157c8221e8a 100644 --- a/apps/desktop/docs/electron-upgrade-checklist.md +++ b/apps/desktop/docs/electron-upgrade-checklist.md @@ -13,5 +13,5 @@ The rendering-parity guarantee (identical to Chrome of the pinned version) is on - Workflow canvas (WebGL/ReactFlow), Monaco editing, a table export download. - Offline page appears with networking off; Retry recovers. 6. **E2E:** `bun run test:e2e` green locally on the new pin; `desktop-e2e.yml` green in CI (the `latest` canary leg should already have hinted at surprises). -7. **Signing/notarization smoke:** run `desktop-release.yml` via `workflow_dispatch` with `publish: false` against a test tag; `spctl`/`stapler` steps must pass. +7. **Signing/notarization smoke:** run `desktop-release.yml` via `workflow_dispatch` with `publish: false`, selecting a test tag under "Use workflow from" and entering the same version; `spctl`/`stapler` steps must pass. 8. **Ship** behind a staged rollout (10% `stagingPercentage`) and watch `update_error` / `renderer_gone` rates in the event logs before raising. diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx index 8b0d35cfeca..0ed7bb43c28 100644 --- a/apps/docs/content/docs/browser-extension/index.mdx +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -1,38 +1,48 @@ --- 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. +description: Ask Sim about a page, dictate a message, and open your team’s go/ 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. +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg), then pin **Sim** in Chrome’s **Extensions** menu. ## 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?” +1. Open a page and click **Sim** in the toolbar. Sign in if prompted. +2. Review the attached page by clicking its chip. Use **×** to remove it. +3. Type your question and send it. -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. +To ask about a passage, select it, right-click, and choose **Ask Sim about selection**. To add another page, open it and use **+ → Current page**. Each attached page stays as it was when captured. -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. +Choose **+ → Page access → Allow across websites** and approve Chrome’s prompt once to use pages across websites. Your draft stays unsent during setup. To change access later, choose **Page access → Manage in browser**. -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. +Turn on **+ → Reference current page on send** to include the active page whenever you send. Sim remembers your choice. Manually attached pages or selections take priority. -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. +Chrome settings, other extensions, and pages without readable text can’t be attached. -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**). +## Dictate or attach a screenshot -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**. +Click the microphone beside Send to dictate. Allow microphone access if Chrome asks, then click again to stop. Edit the draft and send when ready. If access is blocked, choose **Open settings**. -## Roll out to a team +Drag a screenshot file onto Chat, or use the file picker. It uploads as an attachment you can remove before sending. Dragging an image URL from a website doesn’t attach the image itself. -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. +## Keyboard shortcut -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. +Press **Command + Shift + S** on macOS or **Alt + Shift + S** on Windows and Linux. Sim attaches your selection, or the current page when **Reference current page** is off. With it on, the page is included when you send. An unreadable selection stays unsent. -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). +Change the binding in Chrome’s **Extensions → Keyboard shortcuts**. If Chrome keeps focus on the page, click the message field or use its pane-navigation shortcut (**Command + Option + Up** on Mac). -## Page access and privacy +## Open team links -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. +Type a shortcut such as `go/handbook` or `sim/handbook` in Chrome’s address bar. Both work once you're signed in to Sim and Links is on for your organization. + +For suggestions, type `sim`, press **Space**, and enter part of a link name. If Chrome searches instead of opening a shortcut, open the Sim extension and sign in. + +See [Links](/search/sim-links#open-links-from-the-address-bar) for supported inputs, Glean conflicts, and team rollout. Administrators can [allow the extension for a Chrome organizational unit](https://support.google.com/chrome/a/answer/6306504). + +## Privacy + +Sim reads page text when you attach it or send with **Reference current page** on. Approval alone doesn’t start background capture. Attached text appears in the Sim composer before you send; submitted content follows your conversation’s retention settings. + +Voice input sends microphone audio to Sim’s transcription provider while recording. It does not record tab audio or start when you open the panel. The transcript stays in your draft until you send. See [Sim’s browser extension privacy disclosure](https://www.sim.ai/privacy#browser-extension). diff --git a/apps/docs/content/docs/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/platform/enterprise/audit-logs.mdx index 5e3e2677af9..f7f58c486bb 100644 --- a/apps/docs/content/docs/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/platform/enterprise/audit-logs.mdx @@ -101,7 +101,7 @@ Events use identifiers such as `resource.action`. These categories are represent | API keys | `api_key.created`, `api_key.revoked` | | Credentials | `credential.created`, `credential.deleted`, `oauth.disconnected` | | Organization | `organization.updated`, `org_member.added`, `org_member.role_changed`, `organization.sso_provider.updated` | -| Sim links | `sim_link.created`, `sim_link.updated`, `sim_link.deleted` | +| Links | `sim_link.created`, `sim_link.updated`, `sim_link.deleted` | | Exports and drains | `audit_logs.exported`, `data_drain.created`, `data_drain.updated`, `data_drain.ran`, `data_drain.tested` | Authentication failures and password-reset requests use an anonymous actor and record the target account separately. Impersonation and administrative session revocations identify the operator and target organization. Table-row mutations record summaries and counts without copying row contents. diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index 3520c7c0eac..906b6d22573 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -1,11 +1,11 @@ --- -title: Sim Links -description: Short sim/ links that take everyone in your organization to the right page +title: Links +description: Short go/ and sim/ links that take everyone in your organization to the right page --- -Sim Links are short names for pages your team opens often, like `sim/handbook` or `sim/okta`. Every member of your organization can use them, with or without a Search seat. +Links are short names for pages your team opens often, like `handbook` or `okta`. They open as `sim/handbook` or `go/handbook`. Every member of your organization can use them, with or without a Search seat. -Open **Sim Links** in the organization sidebar to see your organization's links. Search by name, destination, or description, or use **Sort → Opens** to see the 50 most opened links. +Open **Links** in the organization sidebar to see your organization's links. Search by name, destination, or description, or use **Sort → Opens** to see the 50 most opened links. ## Open a link @@ -15,15 +15,25 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -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. +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile, then type `go/handbook` or `sim/handbook` in the address bar. Both work once you're signed in to Sim. -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. +For suggestions, type `sim`, press **Space**, and enter part of a name. Matching links appear with the most opened first. Unlisted links still open by name. -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. +### If a shortcut opens search -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. +This can happen if you installed the extension while signed out, or just cleared your browsing history. Opening the Sim extension and signing in fixes the first case, and the second fixes itself within a week. Until then, a trailing slash (`go/handbook/`) or `http://go/handbook` tells Chrome to navigate directly. -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. +### Roll out to your team + +Administrators can [force-install Sim for Chrome](https://support.google.com/chrome/a/answer/6306504) for an organizational unit. Shortcuts start working once each person signs in through the extension, or the next time Chrome starts after they've signed in. + +### If another tool uses go/ or sim + +Sim routes both `go/` and `sim/` to your organization’s links. If Glean or another extension also handles `go/`, Chrome uses the most recently installed extension. Disable the other tool’s go-link handling or remove that extension to use Sim consistently. An existing intranet host named `go` or `sim` conflicts too. + +If `sim` + **Space** still searches while the address bar shows **Sim**, an old Chrome site-search shortcut may use the same keyword. Turn off the Sim extension, rename that shortcut in `chrome://settings/searchEngines`, then turn Sim back on. Ask your administrator if the shortcut is managed. + +### Use links without the extension Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead: @@ -35,17 +45,15 @@ Type `sim`, press **Space**, then type a link name and press **Enter**. For mana ### 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. +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. ## Create a link -1. Open **Sim Links** and select **New link**. +1. Open **Links** and select **New link**. 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. +Right-click an HTTP or HTTPS link on a page and choose **Create link in Sim**. 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**. @@ -67,7 +75,7 @@ Values fill each `{*}` in order, and the last `{*}` takes the rest. A link with By default, any member can edit or delete a link. The link's creator and organization admins can change two options under **Access**: -- **Hide from the Sim Links page**: only the creator and admins see the link in the list, marked **Unlisted**. Anyone with the name can still open it. +- **Hide from the Links page**: only the creator and admins see the link in the list, marked **Unlisted**. Anyone with the name can still open it. - **Only the creator and admins can edit**: only the creator and admins can edit or delete the link, marked **Restricted**. Changes to links are recorded in your organization's [audit logs](/platform/enterprise/audit-logs). diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index b2fd6ea2244..e3881a9b502 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -10,14 +10,20 @@ conversation persistence, streaming recovery, tool permissions, and organization or workspace authorization. The panel keeps Fast mode and omits the model selector. A conversation can continue in the full app. +Voice input uses the existing microphone control beside Send. The browser asks for +microphone permission on first use. Transcription updates the editable draft and +never sends it automatically. Stop ends recording; Send and conversation changes +discard unfinished transcription. The iframe delegates microphone access only to +the configured Sim origin, and blocked access offers the browser's microphone settings. + The user signs in in a normal Sim tab and lands in their organization chat. Choosing a workspace is optional; members without Build access use Assistant. They can open or create a conversation and explicitly attach the current page or highlighted text. The composer’s + menu offers Current page and Selected text. Pending snapshots sit inside the composer as compact removable chips alongside file attachments. Opening one shows the captured text, source, capture time, and conversation-retention disclosure. -Partial captures remain labeled in the attachment row. Site access lives in the -composer’s Site access action, which opens an extension-owned dialog; successful delivery adds no persistent status banner. +Partial captures remain labeled in the attachment row. Page access lives in the +composer’s Page access action, which opens an extension-owned dialog; successful delivery adds no persistent status banner. Snapshots are immutable: navigating to another page cannot silently change an attached source. Removing an attachment, changing organization or workspace, or starting a new conversation clears pending page context. Several explicitly attached pages can support comparison. @@ -34,7 +40,7 @@ Existing Sim server tools remain subject to their existing authorization and app ## Ownership and data flow 1. `apps/extension`: packaged MV3 service worker, side-panel host, bounded content - extraction, per-site permission controls, build artifacts, browser verification. + extraction, browser-wide page permission setup, build artifacts, browser verification. 2. `packages/browser-extension-protocol`: versioned, strictly validated messages and immutable page snapshots shared by the extension, app, and server. 3. `apps/sim/app/extension`: session gate, organization landing, optional workspace picker, app providers, @@ -54,16 +60,20 @@ other workspace resources or browser-control tools. ## Permission and lifetime rules - 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 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. -- No history, bookmarks, cookie API, debugger, native messaging, external messaging, + short-lived session storage for selection handoff, and local storage for preferences. Required host access is + limited to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`, which exist + only so one static redirect rule can send top-level navigations to those hosts to the link resolver. +- HTTP(S) page access is one optional browser-wide grant, requested from a direct + user gesture. The browser owns revocation and restrictions. Approval enables + capture across sites; switching tabs alone does not extract content. +- No history permission, bookmarks, cookie API, debugger, native messaging, external messaging, or arbitrary remote code execution. The only network interception is the static - `sim/` redirect rule above. -- The only background network request is the `sim` address-bar keyword: after + `sim/` and `go/` redirect rule above. +- Background network requests are limited to two. Once a week at browser start, and after + signing in to the panel, the worker asks the Sim origin whether links are on for the session; + if so, it opens `http://go/` and `http://sim/` in background tabs that close themselves, which + teaches Chrome's address bar these hosts and adds entries to browser history. The other is the + `sim` address-bar keyword: after `sim` and Space, the text typed before the first `/` or `?` is sent to the 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 @@ -126,7 +136,7 @@ An absent backend or account is an unverified gate, not a pass. - [Chrome activeTab](https://developer.chrome.com/docs/extensions/develop/concepts/activeTab) and [optional permissions](https://developer.chrome.com/docs/extensions/reference/api/permissions): temporary access follows user invocation; cross-origin navigation requires fresh - authority. Request persistent access per site only when selected by the user. + authority. Request optional access across HTTP(S) websites once, matching Glean’s all-websites page-context option. - [Chrome extension security](https://developer.chrome.com/docs/extensions/develop/security-privacy/stay-secure) and [OWASP extension guidance](https://cheatsheetseries.owasp.org/cheatsheets/Browser_Extension_Vulnerabilities_Cheat_Sheet.html): minimize privileges and treat all incoming page messages as attacker-controlled. diff --git a/apps/extension/README.md b/apps/extension/README.md index 28584cfb483..b5a4621c7d1 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -4,7 +4,7 @@ The Chrome extension attaches an explicitly captured page or selection to the ex [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. +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 Links creation form with a reviewed destination; only selecting **Create** writes a link. ## Build and load @@ -17,7 +17,7 @@ bun run --cwd apps/extension build In Chrome, open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select `apps/extension/dist`. Copy the extension ID into the Sim server's `BROWSER_EXTENSION_IDS` configuration. It accepts comma-separated Chrome extension IDs. Restart the app after changing that configuration. Unconfigured IDs cannot embed the app. -Click the Sim toolbar icon to open chat and attach the current page. **+ → Current page** adds a fresh snapshot; **Selected text** attaches the highlighted passage. Attachments appear as chips inside the composer. Attach up to three pages or selections to summarize, extract facts, or compare sources. To quote a passage, select it and choose **Ask Sim about selection** from the context menu. Review or remove the attachment before sending. **+ → Site access** opens the extension-owned permission dialog: **Allow access to [site]** grants access only to that site; **Remove access to [site]** revokes it. Persistent site permission never enables background capture. +Click the Sim toolbar icon to open chat and attach the current page. **+ → Current page** adds a fresh snapshot; **Selected text** attaches the highlighted passage. Attachments appear as chips inside the composer. Attach pages or selections to summarize, extract facts, or compare sources. To quote a passage, select it and choose **Ask Sim about selection** from the context menu. Review or remove the attachment before sending. **+ → Page access** offers **Allow across websites**. Approve the browser prompt once to use pages across HTTP and HTTPS sites without per-site setup. Manage access in the browser’s extension settings. Permission alone never enables background capture. For local development: @@ -29,21 +29,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/*`. -- 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. +- Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`. +- Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome treats bare `go/` or `sim/` as a search until its history holds that short host, so on install, and at browser start once a week, the extension opens `http://go/` and `http://sim/` in background tabs that close themselves. It does this only once Sim says links are on for the signed-in session, so `go/x` stays an ordinary search for everyone else; signing in through the side panel checks again. Chrome counts a visit to an unfamiliar single-label host as typed, which is what the address bar checks, and repeating covers history that expires after 90 days or is cleared. A trailing slash (`go//`) always selects navigation. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. - 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. +- Optional HTTP/HTTPS access is requested together by **Allow across websites**, from a direct user action. 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. +- Attached pages share a 150,000-character and 450,000-byte snapshot budget per message, within the general 100-context ceiling. Shorter pages can share the available budget without a separate page-count restriction. - 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 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. +- Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. For captures, 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 permission, 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, the native page-access dialog, or the browser's fixed microphone settings page. 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 page-access control and browser approval. - 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. +- The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing and microphone capture are delegated only to the configured Sim origin; clipboard reading and camera access are not delegated. Voice input starts only after the user clicks the microphone and the browser grants permission. The shared Sim voice service streams microphone audio to ElevenLabs for transcription into the editable draft. Stop ends capture while allowing the final transcript to arrive; Send and conversation changes cancel unfinished transcription. There is no tab-audio capture or background recording. -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. +With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. Local storage holds only this preference and when the short hosts were last visited. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. **Allow across websites** works even when Chrome hides the active address; no address entry is required. The grant covers HTTP and HTTPS sites across tabs until the user revokes or restricts it in browser settings. Uploaded screenshot files stay in the hosted app’s normal attachment flow. ## Verification @@ -55,7 +55,7 @@ bun run --cwd apps/extension build The E2E suite uses real Chromium, the packaged extension, and a disposable HTTP app fixture. It pregrants only the fixture page origin in a temporary package to exercise extraction without automating the browser's permission dialog. It checks HttpOnly session-cookie transport, origin/frame spoofing, sensitive-field exclusion, source URL minimization, selected excerpts, UTF-8 limits, stale-document handoffs, denied host access, and absence of page bytes in extension storage. The cookie check uses a fixture, not Better Auth or SSO. JSON results and a screenshot are written beside the caller-supplied report path. The E2E suite builds into a temporary directory and leaves shipping `dist` untouched. -Release verification must additionally exercise the native toolbar and shortcut, Chrome's real grant/revoke prompt, sign-in/SSO and expiry against the running Sim app, side-panel closing/reopening, workspace changes, narrow widths, and the real Mothership streaming response. The fixture suite does not establish those integrations. The design document records launch scope and release criteria. +Release verification must additionally exercise typing `go/` in Chrome's real address bar on a clean profile (the e2e proves the visits, not the address bar), the native toolbar and shortcut, Chrome's real grant/revoke prompt, sign-in/SSO and expiry against the running Sim app, side-panel closing/reopening, workspace changes, narrow widths, and the real Mothership streaming response. The fixture suite does not establish those integrations. The design document records launch scope and release criteria. The authenticated acceptance suite runs against a real local app, Better Auth, and a disposable Postgres database whose name contains `test`. Enable `KNOWLEDGE_MEMBER_ACCESS`, `CREDENTIAL_GROUPS`, `ACCESS_CONTROL_ENABLED`, and `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` on that app to exercise restricted organization members: diff --git a/apps/extension/package.json b/apps/extension/package.json index 577d69f3edf..2529b2b9e69 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@sim/extension", - "version": "0.1.3", + "version": "0.1.5", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/apps/extension/scripts/build.ts b/apps/extension/scripts/build.ts index 7bd61621109..1b44b2f98a3 100644 --- a/apps/extension/scripts/build.ts +++ b/apps/extension/scripts/build.ts @@ -2,6 +2,7 @@ import { createPublicKey } from 'node:crypto' import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { SIM_LINK_HOST_PATTERN, SIM_LINK_HOSTS } from '@sim/browser-extension-protocol' import { build } from 'esbuild' import postcss from 'postcss' import loadPostcssConfig from 'postcss-load-config' @@ -90,7 +91,7 @@ for (const [size, file] of [ await cp(resolve(root, '../sim/public/favicon', file), resolve(dist, `icon-${size}.png`)) } /** - * Only requests to the short host reach this rule. Chrome can classify bare `sim/` as a + * Only requests to the short hosts reach this rule. Chrome can classify a bare `go/` as a * search before navigation. Preserve the path and query for actual short-host navigations. */ const simLinkRules = [ @@ -98,7 +99,10 @@ const simLinkRules = [ id: 1, priority: 1, action: { type: 'redirect', redirect: { regexSubstitution: `${origin.origin}/sim/\\1` } }, - condition: { regexFilter: '^https?://sim/(.*)$', resourceTypes: ['main_frame'] }, + condition: { + regexFilter: `^https?://(?:${SIM_LINK_HOST_PATTERN})/(.*)$`, + resourceTypes: ['main_frame'], + }, }, ] await writeFile(resolve(dist, 'sim-links.json'), JSON.stringify(simLinkRules, null, 2)) @@ -110,7 +114,7 @@ await writeFile( name: development ? 'Sim (Development)' : 'Sim', version: packageJson.version, description: - "Ask Sim about the page you are reading, and open your organization's sim/ links from the address bar.", + "Ask Sim about the page you are reading, and open your organization's go/ and sim/ links from the address bar.", minimum_chrome_version: '120', permissions: [ 'activeTab', @@ -120,7 +124,10 @@ await writeFile( 'contextMenus', 'declarativeNetRequestWithHostAccess', ], - host_permissions: [`${origin.origin}/*`, 'http://sim/*', 'https://sim/*'], + host_permissions: [ + `${origin.origin}/*`, + ...SIM_LINK_HOSTS.flatMap((host) => [`http://${host}/*`, `https://${host}/*`]), + ], declarative_net_request: { rule_resources: [{ id: 'sim_links', enabled: true, path: 'sim-links.json' }], }, diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 8d1da01cd73..151f24c82d3 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -11,10 +11,13 @@ import { type AppToExtensionMessage, extensionToAppMessageSchema, pageSnapshotSchema, + SIM_LINK_HOSTS, + SIM_LINK_SUGGESTIONS_PATH, } from '@sim/browser-extension-protocol' import { SecretReveal } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { renderToStaticMarkup } from 'react-dom/server' @@ -28,6 +31,8 @@ const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; e const secretReveal = renderToStaticMarkup( createElement(SecretReveal, { value: 'SECRET_REVEAL_SENTINEL' }) ) +let linksChecks = 0 +let shortHostVisits = 0 const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, @@ -37,6 +42,14 @@ const fixture = Bun.serve({ return new Response('Fixture session created', { headers: { 'set-cookie': 'fixture-session=present; Path=/; HttpOnly; SameSite=Lax' }, }) + if (url.pathname === SIM_LINK_SUGGESTIONS_PATH) { + linksChecks += 1 + return Response.json({ + enabled: request.headers.get('cookie')?.includes('fixture-session=present') === true, + links: [], + }) + } + if (url.pathname === '/sim/') shortHostVisits += 1 if (url.pathname === '/extension') return new Response( ` @@ -83,14 +96,6 @@ 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 @@ -134,50 +139,6 @@ 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}`], { @@ -187,18 +148,14 @@ try { }) assert(built.status === 0, `Extension build failed: ${built.stderr}`) const manifest = JSON.parse(await readFile(resolve(extensionPath, 'manifest.json'), 'utf8')) - // Pregrants isolate routing from consent; approved search hosts resolve to the local fixture. - manifest.host_permissions.push( - `${pageOrigin}/*`, - ...fixtureSearchHosts.map((host) => `https://${host}/*`) - ) + // Pregrants isolate routing from consent. + manifest.host_permissions.push(`${pageOrigin}/*`) await writeFile(resolve(extensionPath, 'manifest.json'), JSON.stringify(manifest)) context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), { channel: 'chromium', headless: true, ignoreHTTPSErrors: true, args: [ - `--host-resolver-rules=${resolverRules}`, '--no-proxy-server', '--enable-unsafe-extension-debugging', `--disable-extensions-except=${extensionPath}`, @@ -208,6 +165,16 @@ try { const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker')) context.setDefaultTimeout(15_000) const extensionId = worker.url().split('/')[2] + const shortHostsVisitedAt = () => + worker.evaluate( + async () => typeof (await chrome.storage.local.get('shortHostsVisitedAt')).shortHostsVisitedAt + ) + await check('Installing signed out leaves go/ and sim/ as ordinary searches', async () => { + await expect.poll(() => linksChecks, { timeout: 30_000 }).toBeGreaterThan(0) + await sleep(2_000) + assert((await shortHostsVisitedAt()) === 'undefined', 'Visits were recorded while signed out') + assert(shortHostVisits === 0, 'Short hosts were visited while signed out') + }) const signIn = await context.newPage() await signIn.goto(`${origin}/sign-in`) await signIn.close() @@ -360,18 +327,20 @@ try { } await check( - 'HTTP and HTTPS navigations to the sim host reach the configured resolver', + 'HTTP and HTTPS navigations to the sim and go hosts reach the configured resolver', async () => { const tab = await page.context().newPage() for (const scheme of ['http', 'https']) { - 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()}`) + for (const host of SIM_LINK_HOSTS) { + for (const path of [ + 'handbook', + 'handbook/', + 'jira/123?view=board', + 'jira/a%2Fb?view=board%20view', + ]) { + await tab.goto(`${scheme}://${host}/${path}`) + assert(tab.url() === `${origin}/sim/${path}`, `${host}/${path} reached ${tab.url()}`) + } } } await tab.close() @@ -448,7 +417,7 @@ try { 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() + await panel.getByRole('status').filter({ hasText: 'Attaching the page…' }).waitFor() assert((await snapshots()).length === 0, 'Capture crossed before readiness') await panel.evaluate( (sessionId) => @@ -500,6 +469,26 @@ try { ) await page.reload() }) + await check( + 'A signed-in panel visits each short host in tabs that close themselves', + async () => { + await app.evaluate( + (targetOrigin) => + parent.postMessage( + { channel: 'sim-extension', version: 1, type: 'signed-in' }, + targetOrigin + ), + `chrome-extension://${extensionId}` + ) + await expect.poll(shortHostsVisitedAt, { timeout: 30_000 }).toBe('number') + assert(shortHostVisits === SIM_LINK_HOSTS.length, `Visited ${shortHostVisits} short hosts`) + const leftBehind = await worker.evaluate( + async (simLinks) => (await chrome.tabs.query({ url: simLinks })).length, + ['http://go/*', 'http://sim/*', `${origin}/sim/`] + ) + assert(leftBehind === 0, 'Short host tabs were left open') + } + ) await check( 'An expired toolbar intent asks for a fresh capture without delivering content', async () => { @@ -509,7 +498,7 @@ try { await chrome.storage.session.set({ [`captureIntent:${pending.windowId}:expired`]: pending }) }, intent) await expect(panel.getByRole('status')).toHaveText( - 'The capture expired. Attach the page again.' + 'The page attachment expired. Attach it again.' ) assert((await snapshots()).length === before, 'Expired toolbar intent delivered content') } @@ -688,7 +677,7 @@ try { }, intent) await panel .getByRole('status') - .filter({ hasText: /document|frame|changed|cannot/i }) + .filter({ hasText: /document|frame|changed|cannot|unavailable/i }) .waitFor() assert((await snapshots()).length === 3, 'Stale document handoff captured a new document') }) @@ -705,7 +694,7 @@ try { }, intent) await panel .getByRole('status') - .filter({ hasText: /changed before reading/i }) + .filter({ hasText: /The page changed/ }) .waitFor() assert((await snapshots()).length === 3, 'Stale URL handoff captured a different SPA route') }) @@ -715,10 +704,9 @@ try { ) await page.goto('https://ungranted.example/') await capture() - await panel - .getByRole('status') - .filter({ hasText: /access|permission|cannot/i }) - .waitFor() + await expect( + panel.getByRole('button', { name: 'Allow across websites', exact: true }) + ).toBeEnabled() assert((await snapshots()).length === 3, 'Unpermitted page was captured') }) await check('Changing the receiving conversation expires an undelivered capture', async () => { @@ -727,7 +715,7 @@ try { document.body.dataset.ignoreCaptures = 'true' }) await capture() - await panel.getByRole('status').filter({ hasText: 'Waiting for Sim' }).waitFor() + await panel.getByRole('status').filter({ hasText: 'Attaching the page…' }).waitFor() const queuedIntent = await pendingIntent() await worker.evaluate(async (intent) => { await chrome.storage.session.set({ [`captureIntent:${intent.windowId}:queued`]: intent }) @@ -737,14 +725,16 @@ try { delete document.body.dataset.ignoreCaptures }) await ready() - await panel - .getByRole('status') - .filter({ hasText: /conversation changed|previous conversation was discarded/ }) - .waitFor() - await panel - .getByRole('status') - .filter({ hasText: /previous conversation was discarded/ }) - .waitFor() + await expect(panel.getByRole('status')).toHaveText('The chat changed. Attach the page again.') + await expect + .poll(() => + worker.evaluate( + async (key) => (await chrome.storage.session.get(key))[key], + `captureIntent:${queuedIntent.windowId}:queued` + ) + ) + .toBeUndefined() + await settleMessages() assert((await snapshots()).length === 3, 'A capture crossed into another conversation') await capture() await app.waitForFunction( @@ -778,7 +768,10 @@ try { ) await receipt(generateId()) await capture() - await panel.getByRole('status').filter({ hasText: 'previous capture' }).waitFor() + await panel + .getByRole('status') + .filter({ hasText: 'Wait for the page attachment, then try again.' }) + .waitFor() assert((await snapshots()).length === 5, 'A stale receipt released the pending capture') await receipt(receiverSessionId) await panel.waitForFunction(() => document.querySelector('[role="status"]')?.textContent === '') @@ -1058,7 +1051,7 @@ try { ) await panel.bringToFront() await openAccess() - const dialog = panel.getByRole('dialog', { name: 'Site access' }) + const dialog = panel.getByRole('dialog', { name: 'Page access' }) await expect(dialog).toBeVisible() await settleMessages() assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`) @@ -1321,7 +1314,7 @@ try { } ) await check( - 'Site access requests only the address the user confirms, without reading the page', + 'Page access asks the browser for website access without reading before approval', async () => { const source = 'https://private-source.example' const privateUrl = `${source}/sensitive-path?token=PRIVATE_URL_SENTINEL#fragment` @@ -1355,34 +1348,11 @@ try { 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}`) + const allow = panel.getByRole('button', { name: 'Allow across websites', exact: true }) + await expect(allow).toBeEnabled() assert( (await panel.locator('body').getAttribute('data-requested-access')) === null, - 'Typing initiated a permission request' + 'A failed capture initiated a permission request without an explicit action' ) const grantedSites = () => worker.evaluate(async () => ((await chrome.permissions.getAll()).origins ?? []).sort()) @@ -1391,7 +1361,7 @@ try { 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(JSON.parse(requested)).toEqual([{ origins: ['https://*/*', 'http://*/*'] }]) expect(await grantedSites(), 'A site received persistent access without approval').toEqual( previousAccess ) @@ -1404,118 +1374,6 @@ try { 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) { @@ -1530,7 +1388,6 @@ 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 b0a76732c7f..a21909266b0 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -10,7 +10,7 @@ import { } from '@/config' import { captureIntentSchema } from '@/intent' import { registerSimLinkOmnibox } from '@/omnibox' -import { registerSearchRecovery } from '@/search-recovery' +import { registerShortHostVisits } from '@/short-hosts' import { digestUrl } from '@/utils' const logger = createLogger('BrowserExtension') @@ -29,7 +29,7 @@ async function configure() { }) chrome.contextMenus.create({ id: LINK_MENU, - title: 'Create Sim Link', + title: 'Create link in Sim', contexts: ['link'], documentUrlPatterns: ['http://*/*', 'https://*/*'], targetUrlPatterns: ['http://*/*', 'https://*/*'], @@ -91,7 +91,7 @@ function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') } registerSimLinkOmnibox() -registerSearchRecovery() +registerShortHostVisits() chrome.runtime.onInstalled.addListener(() => { void configure().catch(() => logger.warn('Could not initialize extension controls')) }) diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index 984874204a9..143dcc1f29e 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -4,10 +4,12 @@ import { pageSnapshotSchema, } from '@sim/browser-extension-protocol' import { generateId } from '@sim/utils/id' -import { SIM_ORIGIN } from '@/config' +import { PAGE_ACCESS_ORIGINS, SIM_ORIGIN } from '@/config' import { extractPageText } from '@/extract' import { digestUrl } from '@/utils' +export const PAGE_CHANGED_MESSAGE = 'The page changed. Attach it again.' + /** A public capture error whose message is safe to share with the hosted composer. */ export class PageCaptureError extends Error {} @@ -15,7 +17,7 @@ export class PageCaptureError extends Error {} export function captureErrorMessage(error: unknown): string { return error instanceof PageCaptureError ? error.message - : 'This page could not be read. Check site access and try again.' + : 'This page could not be read. Try again.' } /** Only emitted after verifying the selected document is still active. */ @@ -34,7 +36,7 @@ export class SiteAccessRequiredError extends PageCaptureError { readonly tabId: number, readonly windowId: number ) { - super('Allow site access to attach this page.') + super('Page access is required.') } } @@ -49,9 +51,14 @@ export async function captureActivePage( 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.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) + } + if (!tab.url) { + const allowed = await chrome.permissions.contains({ origins: PAGE_ACCESS_ORIGINS }) + signal?.throwIfAborted() + if (allowed) throw new PageCaptureError('This page cannot be attached.') + throw new SiteAccessRequiredError(tab.id, tab.windowId) } - 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 @@ -70,14 +77,12 @@ export async function captureActivePage( 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.') + if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE) return snapshot } catch (error) { - if (changed) throw new PageCaptureError('The page changed while reading. Attach it again.') + if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE) if (expected && !(error instanceof PageCaptureError)) - throw new PageCaptureError( - 'The original document cannot be read. Attach the current page again.' - ) + throw new PageCaptureError('The original page is unavailable. Attach the current page.') throw error } finally { removeListeners() @@ -92,7 +97,7 @@ async function readTab( ) { const url = new URL(tab.url) if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { - throw new PageCaptureError('The page changed before reading. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } let pathname = url.pathname if (url.origin === SIM_ORIGIN) { @@ -129,12 +134,12 @@ async function readTab( world: 'ISOLATED', }) if (identity[0]?.documentId !== result.documentId || identity[0]?.result !== tab.url) { - throw new PageCaptureError('The page changed while reading. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } 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.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } if (result.result.error) { if (kind === 'selection') diff --git a/apps/extension/src/config.ts b/apps/extension/src/config.ts index b6d38c9745b..ccbe186d507 100644 --- a/apps/extension/src/config.ts +++ b/apps/extension/src/config.ts @@ -1,7 +1,9 @@ declare const __SIM_ORIGIN__: string export const SIM_ORIGIN = __SIM_ORIGIN__ +export const PAGE_ACCESS_ORIGINS = ['https://*/*', 'http://*/*'] export const REFERENCE_PAGE_KEY = 'referenceCurrentPage' export const OPEN_SIM_COMMAND = 'open-sim' +export const VISIT_SHORT_HOSTS_MESSAGE = 'visit-short-hosts' 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 b96882f64f2..61182298272 100644 --- a/apps/extension/src/extract.ts +++ b/apps/extension/src/extract.ts @@ -110,8 +110,7 @@ export function extractPageText( } } const text = parts.join('\n') - if (!text) - return { error: 'No readable text was found. This page may use a document viewer or canvas.' } + if (!text) return { error: 'No readable text was found on this page.' } return { url: source.href, title: document.title.slice(0, 512), diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts index 008f9b9a3b7..80ba8ff326d 100644 --- a/apps/extension/src/omnibox.ts +++ b/apps/extension/src/omnibox.ts @@ -12,9 +12,10 @@ 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' +const SEARCH_HINT = 'Find a link in Sim' // Chrome persists this template and substitutes the input when rendering the default row. const OPEN_LINK_HINT = 'Open %s in Sim' +const SEARCH_LINKS_HINT = 'Find %s in Sim' /** A Sim link as suggested: its name, and what it shows beside the name. */ interface SuggestedLink { @@ -31,6 +32,7 @@ interface SuggestedLink { function readTyped(text: string): { name: string; rest: string } { const typed = text .trim() + // Only the keyword's own `sim/`: after it, `go/x` can be a link named `go` with a value. .replace(/^sim\//i, '') .replace(/^\/+/, '') .replace(/#.*$/, '') @@ -139,9 +141,10 @@ export function registerSimLinkOmnibox() { chrome.omnibox.onInputChanged.addListener((text, suggest) => { pending?.abort() const { name, rest } = readTyped(text) - 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) { + const phrase = /\s/.test(name) + showHint(phrase ? SEARCH_LINKS_HINT : name || rest ? OPEN_LINK_HINT : SEARCH_HINT) + if (phrase || name.length > SIM_LINK_NAME_MAX_LENGTH) { suggest([]) return } diff --git a/apps/extension/src/reference.ts b/apps/extension/src/reference.ts index eafd28b586d..2b3890c9370 100644 --- a/apps/extension/src/reference.ts +++ b/apps/extension/src/reference.ts @@ -11,6 +11,8 @@ import { } from '@/capture' import { REFERENCE_PAGE_KEY } from '@/config' +export const PAGE_ATTACHMENT_PENDING_MESSAGE = 'Wait for the page attachment, then try again.' + interface ReferenceOptions { session: () => string | null post: (message: ExtensionToAppMessage) => void @@ -124,8 +126,7 @@ export function createReferenceCapture({ replyError( sessionId, requestId, - manualError?.message ?? - 'A page attachment is still being prepared. Try again once it appears.', + manualError?.message ?? PAGE_ATTACHMENT_PENDING_MESSAGE, manualError?.reason ) return @@ -169,8 +170,7 @@ export function createReferenceCapture({ refreshPreference, setPreference, busy: () => pending !== null, - manualRequested: () => - cancel('An explicit attachment was requested. Try again after it appears.'), + manualRequested: () => cancel('Wait for the page attachment, then send again.'), preferenceChanged(value: unknown) { if (preferenceWrite) return revision++ diff --git a/apps/extension/src/search-recovery-dialog.tsx b/apps/extension/src/search-recovery-dialog.tsx deleted file mode 100644 index bc89f7e943b..00000000000 --- a/apps/extension/src/search-recovery-dialog.tsx +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index f89356ad0a1..00000000000 --- a/apps/extension/src/search-recovery.ts +++ /dev/null @@ -1,198 +0,0 @@ -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/short-hosts.ts b/apps/extension/src/short-hosts.ts new file mode 100644 index 00000000000..6776723b467 --- /dev/null +++ b/apps/extension/src/short-hosts.ts @@ -0,0 +1,81 @@ +import { + SIM_LINK_HOSTS, + SIM_LINK_SUGGESTIONS_PATH, + simLinkSuggestionsSchema, +} from '@sim/browser-extension-protocol' +import { createLogger } from '@sim/logger' +import { SIM_ORIGIN, VISIT_SHORT_HOSTS_MESSAGE } from '@/config' + +const logger = createLogger('ShortHosts') +const VISITED_AT_KEY = 'shortHostsVisitedAt' +const REFRESH_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000 +const VISIT_TIMEOUT_MS = 15_000 + +/** False when signed out or links are off here, where `go/x` should stay an ordinary search. */ +async function linksAreEnabled(): Promise { + const response = await fetch(`${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}`, { + credentials: 'include', + }) + if (!response.ok) return false + return simLinkSuggestionsSchema.parse(await response.json()).enabled === true +} + +/** + * Closes once Chrome has committed the visit, which is what records the host in its history. + * False when it never got that far, such as the person closing the tab first. + */ +async function visitInBackground(host: string): Promise { + const { id: tabId } = await chrome.tabs.create({ url: `http://${host}/`, active: false }) + if (tabId === undefined) return false + const visited = await new Promise((resolve) => { + const finish = (committed: boolean) => { + clearTimeout(timeout) + chrome.tabs.onUpdated.removeListener(updated) + resolve(committed) + } + const updated = (id: number, change: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab) => { + if (id === tabId && (change.status === 'complete' || tab.url?.startsWith(SIM_ORIGIN))) { + finish(true) + } + } + const timeout = setTimeout(() => finish(false), VISIT_TIMEOUT_MS) + chrome.tabs.onUpdated.addListener(updated) + }) + await chrome.tabs.remove(tabId).catch(() => undefined) + return visited +} + +/** + * Chrome searches for `go/handbook` until its history holds `go` as a typed host, and it counts a + * visit to an unfamiliar single-label host as typed. So a background visit teaches the address bar + * without asking anything of the user. History expires after 90 days and can be cleared, which is + * why this repeats: a later visit only adds a history entry while the host is known, and relearns + * it once it isn't. + */ +async function visitShortHosts() { + const stored = await chrome.storage.local.get(VISITED_AT_KEY) + const visitedAt = stored[VISITED_AT_KEY] + if (typeof visitedAt === 'number' && Date.now() - visitedAt < REFRESH_INTERVAL_MS) return + if (!(await linksAreEnabled())) return + const visits = await Promise.all(SIM_LINK_HOSTS.map(visitInBackground)) + if (visits.every(Boolean)) await chrome.storage.local.set({ [VISITED_AT_KEY]: Date.now() }) +} + +export function registerShortHostVisits() { + let visiting = false + const visit = () => { + if (visiting) return + visiting = true + void visitShortHosts() + .catch(() => logger.warn('Could not visit the short link hosts')) + .finally(() => { + visiting = false + }) + } + chrome.runtime.onInstalled.addListener(visit) + chrome.runtime.onStartup.addListener(visit) + // Installing signed out leaves nothing to learn yet, so signing in to the panel is the next chance. + chrome.runtime.onMessage.addListener((message: unknown, sender) => { + if (sender.id === chrome.runtime.id && message === VISIT_SHORT_HOSTS_MESSAGE) visit() + }) +} diff --git a/apps/extension/src/shortcut-hint.tsx b/apps/extension/src/shortcut-hint.tsx deleted file mode 100644 index 543390bdb1c..00000000000 --- a/apps/extension/src/shortcut-hint.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 dc74d3d56f8..9c5b764f80d 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -4,28 +4,33 @@ import { type ExtensionToAppMessage, type PageSnapshot, } from '@sim/browser-extension-protocol' -import { Chip } from '@sim/emcn' import { createRoot } from 'react-dom/client' import { captureActivePage, captureErrorMessage, + PAGE_CHANGED_MESSAGE, PageCaptureError, SiteAccessRequiredError, UnreadableSelectionError, } from '@/capture' -import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config' +import { + CAPTURE_INTENT_KEY, + CAPTURE_INTENT_TTL_MS, + REFERENCE_PAGE_KEY, + SIM_ORIGIN, + VISIT_SHORT_HOSTS_MESSAGE, +} 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 { createReferenceCapture, PAGE_ATTACHMENT_PENDING_MESSAGE } from '@/reference' +import { PageAccessButton, SiteAccessDialog } from '@/site-access' import '@/shell.css' +const PAGE_ATTACHMENT_EXPIRED_MESSAGE = 'The page attachment expired. Attach it again.' 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.' + ? 'Click the message field, or press ⌘⌥↑ to move focus.' + : 'Click the message field, or press F6 to move focus.' interface SidePanelProps { origin: string @@ -43,7 +48,6 @@ function SidePanel({ origin }: SidePanelProps) { const intentCutoffRef = useRef(0) const busyRef = useRef(false) const [accessOpen, setAccessOpen] = useState(false) - const [shortcutsOpen, setShortcutsOpen] = useState(false) const [status, setStatus] = useState('') const referenceRef = useRef | null>(null) @@ -51,9 +55,7 @@ function SidePanel({ origin }: SidePanelProps) { 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 - ) + setStatus((current) => (current === status ? PAGE_CHANGED_MESSAGE : current)) } } const activated = (tab: chrome.tabs.OnActivatedInfo) => { @@ -76,7 +78,7 @@ function SidePanel({ origin }: SidePanelProps) { 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.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) return } const message: ExtensionToAppMessage = { @@ -111,9 +113,7 @@ function SidePanel({ origin }: SidePanelProps) { 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.') + if (pendingRef.current || busyRef.current) setStatus('The chat changed. Attach the page again.') captureGenerationRef.current++ pendingRef.current = null deliveredRef.current = null @@ -133,12 +133,12 @@ function SidePanel({ origin }: SidePanelProps) { } ) { if (referenceRef.current?.busy()) { - setStatus('Wait for the current message to finish attaching its page.') + setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE) return } if (busyRef.current) return if (pendingRef.current) { - setStatus('Waiting for Sim to receive the previous capture. Try again after it appears.') + setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE) return } busyRef.current = true @@ -150,7 +150,7 @@ function SidePanel({ origin }: SidePanelProps) { focusRequested.current = expected?.focusComposer === true unreadableSelection.current = null pendingRef.current = snapshot - setStatus('Waiting for Sim to receive the attachment…') + setStatus('Attaching the page…') deliver() } catch (error) { if (generation !== captureGenerationRef.current) return @@ -174,7 +174,7 @@ function SidePanel({ origin }: SidePanelProps) { manualCaptureError: () => { if (busyRef.current || pendingRef.current || processingManual || manualIntents.size) return { - message: 'A page attachment is still being prepared. Try again once it appears.', + message: PAGE_ATTACHMENT_PENDING_MESSAGE, } if (unreadableSelection.current) return { message: BLOCKED_SELECTION, reason: 'selection' } }, @@ -247,15 +247,12 @@ function SidePanel({ origin }: SidePanelProps) { for (const key of keys) manualIntents.delete(key) const intent = intents[0] if (intent && intent.createdAt <= intentCutoffRef.current) { - if (!disposed) - setStatus( - 'A capture from the previous conversation was discarded. Attach the page again.' - ) + if (!disposed) setStatus('The chat changed. Attach the page again.') return } if (disposed || !intent) return if (!currentIntent(intent, window.id)) { - setStatus('The capture expired. Attach the page again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) return } if (intent.kind === 'focus') { @@ -266,7 +263,7 @@ function SidePanel({ origin }: SidePanelProps) { } } else await capture(intent.kind, intent) } catch { - if (!disposed) setStatus('Could not receive the capture request. Attach the page again.') + if (!disposed) setStatus('Could not attach the page. Try again.') } finally { processingManual = false consumingIntent = false @@ -316,13 +313,6 @@ function SidePanel({ origin }: SidePanelProps) { 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() } @@ -333,6 +323,8 @@ function SidePanel({ origin }: SidePanelProps) { if (parsed.data.type === 'theme') { appTheme = parsed.data.theme applyTheme() + } else if (parsed.data.type === 'signed-in') { + void chrome.runtime.sendMessage(VISIT_SHORT_HOSTS_MESSAGE).catch(() => undefined) } else if (parsed.data.type === 'ready') { void connect(parsed.data.sessionId) } else if (parsed.data.sessionId !== sessionRef.current) { @@ -350,7 +342,8 @@ function SidePanel({ origin }: SidePanelProps) { } 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 if (parsed.data.action === 'microphone-settings') + void chrome.tabs.create({ url: 'chrome://settings/content/microphone' }) else void capture(parsed.data.action === 'capture-page' ? 'page' : 'selection') } else if (pendingRef.current?.id === parsed.data.snapshotId) { pendingRef.current = null @@ -366,7 +359,7 @@ function SidePanel({ origin }: SidePanelProps) { ) { pendingRef.current = null focusRequested.current = false - setStatus('The capture expired. Attach the page again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) } void consumeIntent() }, 5_000) @@ -402,18 +395,20 @@ function SidePanel({ origin }: SidePanelProps) { } role='status' > - {typeof status === 'string' ? status : status.message} - {status instanceof SiteAccessRequiredError && ( - <> - setAccessOpen(true)}> - Request site access - - - + {status instanceof SiteAccessRequiredError ? ( + + setStatus((current) => (current instanceof SiteAccessRequiredError ? '' : current)) + } + /> + ) : typeof status === 'string' ? ( + status + ) : ( + status.message )}