diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db922131867..91a565b8275 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1308,8 +1308,12 @@ jobs: needs.desktop-signing.result == 'success' && needs.version.result == 'success' && needs.desktop-signing.outputs.configured == 'true' + # The reusable workflow's Windows job requests id-token: write for Azure + # OIDC, and GitHub checks every called job against the caller's grant at + # startup, even one this macOS call skips. permissions: contents: write + id-token: write uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.version.outputs.version }} @@ -1323,6 +1327,57 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Windows desktop builds are signed with Azure Artifact Signing over GitHub + # OIDC. Gated like the macOS build: until the Azure identity and signing + # variables exist, the Windows build skips with a warning, and nothing is + # ever published unsigned. Windows jobs are separate workflow calls, and a + # Windows failure never blocks a macOS release; the update feed treats a + # release without latest.yml as having no Windows build. The prerelease path + # still waits for them: prerelease-publish needs prerelease-build-windows, so + # it starts only once that job finishes, whatever its result. + desktop-signing-windows: + name: desktop-signing-windows + runs-on: *runner-2vcpu + timeout-minutes: 2 + needs: [version, desktop-changes] + if: ${{ !cancelled() && (needs.version.outputs.is_release == 'true' || needs.desktop-changes.outputs.changed == 'true') }} + outputs: + configured: ${{ steps.check.outputs.configured }} + steps: + - name: Probe Azure signing configuration + id: check + env: + CONFIGURED: ${{ secrets.AZURE_CLIENT_ID != '' && secrets.AZURE_TENANT_ID != '' && secrets.AZURE_SUBSCRIPTION_ID != '' && vars.AZURE_SIGNING_ENDPOINT != '' && vars.AZURE_SIGNING_ACCOUNT != '' && vars.AZURE_SIGNING_PROFILE != '' && vars.AZURE_SIGNING_PUBLISHER != '' }} + run: | + echo "configured=${CONFIGURED}" >> "$GITHUB_OUTPUT" + if [ "$CONFIGURED" != "true" ]; then + echo "::warning::Windows desktop release skipped: Azure signing is not configured (secrets AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID; variables AZURE_SIGNING_ENDPOINT, AZURE_SIGNING_ACCOUNT, AZURE_SIGNING_PROFILE, AZURE_SIGNING_PUBLISHER)." + fi + + desktop-release-windows: + name: desktop-release-windows + needs: [release, desktop-signing-windows, version] + # See desktop-release for why the implicit success() check is suppressed. + if: >- + !cancelled() && + needs.release.result == 'success' && + needs.desktop-signing-windows.result == 'success' && + needs.version.result == 'success' && + needs.desktop-signing-windows.outputs.configured == 'true' + permissions: + contents: write + id-token: write + uses: ./.github/workflows/desktop-release.yml + with: + version: ${{ needs.version.outputs.version }} + publish: true + platform: windows + secrets: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # Per-env desktop prereleases: a dev/staging push that touches shell code # publishes an environment-tagged GitHub prerelease to the public, # release-only simstudioai/sim-desktop-releases repository. Keeping these @@ -1333,15 +1388,20 @@ jobs: name: prerelease-create runs-on: *runner-2vcpu timeout-minutes: 5 - needs: [desktop-changes, desktop-signing] - # Requires the signing probe to have actually succeeded (not just "not + needs: [desktop-changes, desktop-signing, desktop-signing-windows] + # Requires a signing probe to have actually succeeded (not just "not # cancelled") so a probe failure can't produce a release with no build. + # Either platform's signing is enough: each build job gates on its own. if: >- ${{ !cancelled() && needs.desktop-changes.outputs.changed == 'true' && - needs.desktop-signing.result == 'success' && - needs.desktop-signing.outputs.configured == 'true' + ( + (needs.desktop-signing.result == 'success' && + needs.desktop-signing.outputs.configured == 'true') || + (needs.desktop-signing-windows.result == 'success' && + needs.desktop-signing-windows.outputs.configured == 'true') + ) }} permissions: contents: read @@ -1403,11 +1463,19 @@ jobs: prerelease-build: name: prerelease-build needs: [prerelease-create, desktop-signing] + if: >- + !cancelled() && + needs.prerelease-create.result == 'success' && + needs.desktop-signing.result == 'success' && + needs.desktop-signing.outputs.configured == 'true' # The reusable workflow declares contents: write for its stable-release # path. GitHub cannot elevate a caller's token, even though this prerelease # path uses the dedicated cross-repository token for its actual upload. + # id-token: write is required the same way by the Windows job (see + # desktop-release above). permissions: contents: write + id-token: write uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.prerelease-create.outputs.version }} @@ -1422,6 +1490,31 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Attaches the Windows build to the same prerelease. A Windows failure never + # holds back a macOS build that succeeded (see prerelease-publish). + prerelease-build-windows: + name: prerelease-build-windows + needs: [prerelease-create, desktop-signing-windows] + if: >- + !cancelled() && + needs.prerelease-create.result == 'success' && + needs.desktop-signing-windows.result == 'success' && + needs.desktop-signing-windows.outputs.configured == 'true' + permissions: + contents: write + id-token: write + uses: ./.github/workflows/desktop-release.yml + with: + version: ${{ needs.prerelease-create.outputs.version }} + publish: true + sign: true + platform: windows + secrets: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # The draft only becomes visible to the update feed once its artifacts are # attached — this is what makes a dev/staging push atomic from the shell's # point of view. @@ -1429,7 +1522,17 @@ jobs: name: prerelease-publish runs-on: *runner-2vcpu timeout-minutes: 5 - needs: [prerelease-create, prerelease-build] + needs: [prerelease-create, prerelease-build, prerelease-build-windows] + # Publishes once every configured platform has finished: the macOS build + # when Apple signing is configured, otherwise the Windows build alone. + if: >- + !cancelled() && + needs.prerelease-create.result == 'success' && + ( + needs.prerelease-build.result == 'success' || + (needs.prerelease-build.result == 'skipped' && + needs.prerelease-build-windows.result == 'success') + ) permissions: contents: read env: diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index bbf18bb6d15..40384231379 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -323,3 +323,72 @@ jobs: exit 1 fi SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts + + # The Windows leg: the unit suite on Windows, then an unsigned unpacked x64 + # build through the same packaged smoke suite (fuses, launch telemetry, and + # the bundled offline page). + package-smoke-windows: + name: package-smoke-windows + if: github.event_name != 'schedule' && github.event_name != 'push' + runs-on: windows-latest + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.4 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: 22 + + - name: Cache Electron downloads + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: desktop-downloads-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: desktop-downloads-${{ runner.os }}- + + - name: Install dependencies + env: + HUSKY: '0' + run: bun install --frozen-lockfile + + - name: Run desktop unit tests + working-directory: apps/desktop + run: bun run test + + - name: Bundle and package unsigned + working-directory: apps/desktop + run: | + bun run build + bunx electron-builder --win dir --x64 --publish never -c.win.signExecutable=false + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + run: | + EXECUTABLE=release/win-unpacked/Sim.exe + if [ ! -f "$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 test results + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: desktop-package-smoke-windows-results + path: apps/desktop/test-results + retention-days: 7 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 836f2a2644d..d7aa727656d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -5,6 +5,10 @@ name: desktop-release # releases live in simstudioai/sim-desktop-releases. Ordering is load-bearing: # scripts/create-single-release.ts skips creation when the stable tag already # exists, so this workflow must never create a release itself. +# +# One call builds one platform. Callers run macOS and Windows as separate calls +# so a Windows failure never holds back the macOS release: the update feed +# treats a release without latest.yml as having no Windows build. on: workflow_call: @@ -19,11 +23,16 @@ on: type: boolean default: true sign: - description: Sign and notarize with the Apple Developer identity. Unsigned - builds are workflow artifacts only and cannot be published. + description: Sign (and on macOS notarize) the build. Unsigned builds are + workflow artifacts only and cannot be published. required: false type: boolean default: true + platform: + description: Desktop platform to build (mac or windows) + required: false + type: string + default: mac secrets: DESKTOP_RELEASE_TOKEN: required: false @@ -39,6 +48,12 @@ on: required: false APPLE_TEAM_ID: required: false + AZURE_CLIENT_ID: + required: false + AZURE_TENANT_ID: + required: false + AZURE_SUBSCRIPTION_ID: + required: false workflow_dispatch: inputs: version: @@ -51,10 +66,16 @@ on: type: boolean default: false sign: - description: Sign and notarize with the Apple Developer identity + description: Sign (and on macOS notarize) the build required: false type: boolean default: true + platform: + description: Desktop platform to build + required: false + type: choice + options: [mac, windows] + default: mac permissions: contents: read @@ -67,6 +88,7 @@ jobs: outputs: name: ${{ steps.channel.outputs.name }} app_id: ${{ steps.channel.outputs.app_id }} + slug: ${{ steps.channel.outputs.slug }} origin: ${{ steps.channel.outputs.origin }} release_repository: ${{ steps.channel.outputs.release_repository }} token_kind: ${{ steps.channel.outputs.token_kind }} @@ -81,7 +103,10 @@ jobs: # accepted while already-published builds age out. The channel decides the app's # identity (name/bundle id — a separate app per environment, installable # side by side) and the default origin baked into the bundle, which in - # turn selects the update feed the installed app polls. + # turn selects the update feed the installed app polls. SLUG is the + # Windows package name (-c.extraMetadata.name): one-click NSIS installs + # derive their install directory and updater cache from it, so each + # channel needs its own. - name: Resolve channel identity id: channel env: @@ -89,15 +114,16 @@ jobs: run: | case "$VERSION" in *-dev.*|*-alpha.*) - NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;; + NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; SLUG=sim-dev; ORIGIN=https://www.dev.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;; *-staging.*|*-beta.*) - NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;; + NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; SLUG=sim-staging; ORIGIN=https://www.staging.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;; *) - NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN=''; RELEASE_REPOSITORY="$GITHUB_REPOSITORY"; TOKEN_KIND=stable ;; + NAME='Sim'; APP_ID=ai.sim.desktop; SLUG=sim; ORIGIN=''; RELEASE_REPOSITORY="$GITHUB_REPOSITORY"; TOKEN_KIND=stable ;; esac { echo "name=$NAME" echo "app_id=$APP_ID" + echo "slug=$SLUG" echo "origin=$ORIGIN" echo "release_repository=$RELEASE_REPOSITORY" echo "token_kind=$TOKEN_KIND" @@ -110,11 +136,28 @@ jobs: SIGN: ${{ inputs.sign }} TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} VERSION: ${{ inputs.version }} + PLATFORM: ${{ inputs.platform }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }} 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 [ "$PLATFORM" != mac ] && [ "$PLATFORM" != windows ]; then + echo "::error::Unknown desktop platform '$PLATFORM'; expected mac or windows." + exit 1 + fi + if [ "$PLATFORM" = windows ] && [ "$SIGN" = true ]; then + for NAME in AZURE_SIGNING_ENDPOINT AZURE_SIGNING_ACCOUNT AZURE_SIGNING_PROFILE AZURE_SIGNING_PUBLISHER; do + if [ -z "${!NAME}" ]; then + echo "::error::Repository variable $NAME is required to sign the Windows build." + exit 1 + fi + done + 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 @@ -149,9 +192,12 @@ jobs: exit 1 fi + # The sharded unpackaged Electron suite runs in the macOS call only; the + # Windows call runs the desktop unit and packaged smoke suites in build-windows. smoke: name: smoke (${{ matrix.shard }}/${{ strategy.job-total }}) needs: source + if: ${{ inputs.platform != 'windows' }} runs-on: macos-26 timeout-minutes: 30 strategy: @@ -256,6 +302,7 @@ jobs: build: name: build needs: source + if: ${{ inputs.platform != 'windows' }} outputs: artifact_id: ${{ steps.artifacts.outputs.artifact-id }} runs-on: macos-26 @@ -502,10 +549,213 @@ jobs: apps/desktop/release/latest-mac.yml retention-days: 7 + # Windows: a per-user NSIS installer for x64, Authenticode-signed through + # Azure Artifact Signing. GitHub OIDC logs in to Azure, so no signing secret + # is stored here; the signing account and profile come from repository + # variables and are passed as -c flags. Like build, it restores no caches. + build-windows: + name: build-windows + needs: source + if: ${{ inputs.platform == 'windows' }} + outputs: + artifact_id: ${{ steps.artifacts.outputs.artifact-id }} + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: read + id-token: write + defaults: + run: + shell: bash + steps: + - *checkout-source + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.4 + no-cache: true + + - *setup-node + + - name: Install dependencies + env: + HUSKY: '0' + run: bun install --frozen-lockfile + + - *inject-version + + # Lint and type-check are platform-independent and run in the macOS + # build; the unit suite runs here for the Windows code paths. + - name: Verify desktop source + run: bun run --cwd apps/desktop test + + - *bundle-desktop + + - name: Log in to Azure + if: ${{ inputs.sign }} + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # forceCodeSigning fails the build when no signing configuration is + # found (electron-builder 26 checks for one, not each file's signature), + # so a missing Azure setup cannot produce an unsigned installer. publisherName also lands in resources/app-update.yml, where the + # installed app's updater checks each downloaded installer against it. + - name: Package and sign + if: ${{ inputs.sign }} + working-directory: apps/desktop + env: + PRODUCT_NAME: ${{ needs.source.outputs.name }} + APP_ID: ${{ needs.source.outputs.app_id }} + SLUG: ${{ needs.source.outputs.slug }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }} + run: > + bunx electron-builder --win --x64 --publish never + -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" -c.extraMetadata.name="$SLUG" + -c.win.azureSignOptions.publisherName="$AZURE_SIGNING_PUBLISHER" + -c.win.azureSignOptions.endpoint="$AZURE_SIGNING_ENDPOINT" + -c.win.azureSignOptions.codeSigningAccountName="$AZURE_SIGNING_ACCOUNT" + -c.win.azureSignOptions.certificateProfileName="$AZURE_SIGNING_PROFILE" + -c.forceCodeSigning=true + + - name: Log out of Azure + if: ${{ always() && inputs.sign }} + run: az logout || true + + # Unsigned artifact-only path for workflow testing; never published. + - name: Package unsigned + if: ${{ !inputs.sign }} + working-directory: apps/desktop + env: + PRODUCT_NAME: ${{ needs.source.outputs.name }} + APP_ID: ${{ needs.source.outputs.app_id }} + SLUG: ${{ needs.source.outputs.slug }} + run: > + bunx electron-builder --win --x64 --publish never + -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" -c.extraMetadata.name="$SLUG" + -c.win.signExecutable=false + + - name: Validate packaged artifacts + env: + VERSION: ${{ inputs.version }} + PRODUCT_NAME: ${{ needs.source.outputs.name }} + SLUG: ${{ needs.source.outputs.slug }} + run: | + SEMVER="${VERSION#v}" + RELEASE_DIR=apps/desktop/release + INSTALLER="$RELEASE_DIR/Sim-${SEMVER}-x64.exe" + MANIFEST="$RELEASE_DIR/latest.yml" + for ARTIFACT in "$INSTALLER" "$INSTALLER.blockmap" "$MANIFEST" "$RELEASE_DIR/win-unpacked/$PRODUCT_NAME.exe"; do + if [ ! -f "$ARTIFACT" ]; then + echo "::error::Expected desktop artifact is missing: $ARTIFACT" + exit 1 + fi + done + if [ "$(find "$RELEASE_DIR" -maxdepth 1 \( -name '*.exe' -o -name '*.blockmap' -o -name '*.yml' \) ! -name builder-debug.yml ! -name builder-effective-config.yaml | wc -l | tr -d ' ')" != 3 ]; 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" "$MANIFEST"; 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' "$MANIFEST")" + if [ "$URLS" != "Sim-${SEMVER}-x64.exe" ]; then + echo "::error::Updater manifest contains unexpected artifact URLs." + exit 1 + fi + if ! grep -Fxq "path: Sim-${SEMVER}-x64.exe" "$MANIFEST"; then + echo "::error::Updater manifest path does not reference the verified installer." + exit 1 + fi + DIGEST="$(node -e "process.stdout.write(require('crypto').createHash('sha512').update(require('fs').readFileSync(process.argv[1])).digest('base64'))" "$INSTALLER")" + if [ "$(grep -c "sha512: ${DIGEST}\$" "$MANIFEST")" -lt 1 ]; then + echo "::error::Updater manifest sha512 does not match the installer." + exit 1 + fi + # Each channel must keep its own updater cache (see Resolve channel identity). + APP_UPDATE="$RELEASE_DIR/win-unpacked/resources/app-update.yml" + if ! grep -Fxq "updaterCacheDirName: ${SLUG}-updater" "$APP_UPDATE"; then + echo "::error::app-update.yml does not carry the ${SLUG} updater cache." + exit 1 + fi + + # Without publisherName in app-update.yml, electron-updater skips the + # signature check on downloaded installers entirely. + - name: Validate signatures + if: ${{ inputs.sign }} + shell: pwsh + env: + VERSION: ${{ inputs.version }} + PRODUCT_NAME: ${{ needs.source.outputs.name }} + AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }} + run: | + $ErrorActionPreference = 'Stop' + $semver = $env:VERSION -replace '^v', '' + $files = @( + "apps/desktop/release/Sim-$semver-x64.exe", + "apps/desktop/release/win-unpacked/$($env:PRODUCT_NAME).exe" + ) + foreach ($file in $files) { + $signature = Get-AuthenticodeSignature -FilePath $file + if ($signature.Status -ne 'Valid') { + Write-Output "::error::$file is not validly signed: $($signature.Status) $($signature.StatusMessage)" + exit 1 + } + $commonName = $signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + if ($commonName -ne $env:AZURE_SIGNING_PUBLISHER) { + Write-Output "::error::$file is signed by '$commonName', not '$($env:AZURE_SIGNING_PUBLISHER)'." + exit 1 + } + } + $appUpdate = Get-Content -Raw 'apps/desktop/release/win-unpacked/resources/app-update.yml' + $publisherLine = '^\s*-\s*[''"]?' + [regex]::Escape($env:AZURE_SIGNING_PUBLISHER) + '[''"]?\s*$' + if ($appUpdate -notmatch '(?m)^publisherName:' -or $appUpdate -notmatch "(?m)$publisherLine") { + Write-Output "::error::app-update.yml does not pin the expected publisherName; updates would install unverified." + exit 1 + } + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + env: + PRODUCT_NAME: ${{ needs.source.outputs.name }} + run: SIM_DESKTOP_EXECUTABLE="release/win-unpacked/$PRODUCT_NAME.exe" bunx playwright test e2e/packaged-smoke.spec.ts + + - name: Upload artifacts to the workflow run + id: artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: sim-desktop-windows-${{ inputs.version }} + compression-level: 0 + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.blockmap + apps/desktop/release/latest.yml + retention-days: 7 + + # Exactly one of build and build-windows runs per call, so the implicit + # success() check is replaced by explicit results for the platform built. publish: name: publish - needs: [source, build, smoke] - if: inputs.publish + needs: [source, build, build-windows, smoke] + if: >- + !cancelled() && + inputs.publish && + needs.source.result == 'success' && + ( + (inputs.platform == 'windows' && + needs.build-windows.result == 'success') || + (inputs.platform != 'windows' && + needs.build.result == 'success' && + needs.smoke.result == 'success') + ) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -514,12 +764,13 @@ jobs: - name: Download verified desktop artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - artifact-ids: ${{ needs.build.outputs.artifact_id }} + artifact-ids: ${{ inputs.platform == 'windows' && needs.build-windows.outputs.artifact_id || needs.build.outputs.artifact_id }} path: apps/desktop/release - name: Upload artifacts to the release env: DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + PLATFORM: ${{ inputs.platform }} RELEASE_REPOSITORY: ${{ needs.source.outputs.release_repository }} SOURCE_RELEASE_TOKEN: ${{ github.token }} TOKEN_KIND: ${{ needs.source.outputs.token_kind }} @@ -550,13 +801,23 @@ jobs: fi RELEASE_JSON="$(gh api "repos/${RELEASE_REPOSITORY}/releases/${RELEASE_ID}")" 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" - ) + # The updater manifest goes last: the feed only offers a build once + # its manifest exists, so it must never point at a missing package. + if [ "$PLATFORM" = windows ]; then + ARTIFACTS=( + "apps/desktop/release/Sim-${SEMVER}-x64.exe" + "apps/desktop/release/Sim-${SEMVER}-x64.exe.blockmap" + "apps/desktop/release/latest.yml" + ) + else + 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" + ) + fi upload_or_verify() { local ARTIFACT="$1" local NAME SIZE DIGEST REMOTE REMOTE_SIZE REMOTE_DIGEST @@ -576,7 +837,6 @@ jobs: fi gh release upload "$VERSION" "$ARTIFACT" --repo "$RELEASE_REPOSITORY" } - for ARTIFACT in "${ARTIFACTS[@]:0:4}"; do + for ARTIFACT in "${ARTIFACTS[@]}"; do upload_or_verify "$ARTIFACT" done - upload_or_verify "${ARTIFACTS[4]}" diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index e7ec8f6297f..07ddd3ecbbf 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,5 +1,6 @@ dist/ release/ build/generated-icon.icon +build/generated-icon.ico playwright-report/ test-results/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ea7e867477e..1ddcd34b096 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,4 +1,4 @@ -# Sim Desktop (macOS) +# Sim Desktop (macOS and Windows) A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://www.sim.ai` — the origin the server actually serves; the apex 301s there) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled. @@ -198,15 +198,42 @@ The native `read_local_file` and `import_local_files` tools also accept absolute **Desktop settings → Local files → Full file access** bypasses folder prompts for authorized native reads and imports. It is off by default, persists across ordinary restarts, and resets on sign-out or server changes. Turning it off restores folder consent checks. Call authorization, cancellation, file identity, and resource limits still apply, and VFS access continues to use explicit mounts. A failed settings write reports an error and leaves full access disabled in the running app. -Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. Directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges. +Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. On macOS and Linux, a native module opens each path component relative to the verified grant descriptor, and directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the open or the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges. + +On Windows, Node cannot open a path relative to a directory handle, so the same tools use checked Node fs opens instead (`src/main/win32-native-files.ts`). Paths are validated lexically first: only drive paths and `~\` paths are accepted, and reserved device names, alternate data streams, and trailing dots or spaces are refused. Then Electron checks that the granted folder still has its recorded identity, resolves the path and refuses it unless it is already canonical beneath the granted folder (so any symlink or junction in it, even one pointing back inside, is refused), and opens it only when the opened handle has the identity the path had just before the open; after the open it checks the path and the granted folder again. A directory listing must leave the path naming the opened handle. Listings report symlinks and junctions as links and never follow them, under the same caps as macOS. These checks narrow, but cannot eliminate, a race by a process already running as the user that swaps a path component between a check and the open: unlike macOS's `openat(O_NOFOLLOW)` walk, the open follows a swapped-in junction or symlink (the identity check then refuses any handle that is not the checked file, but a symlink to a share has already been contacted). The model itself cannot create links or junctions: the tools are read-only. OneDrive Files-On-Demand placeholders are downloaded when they are read or imported. UNC and other network paths, including a drive letter mapped to a share, are refused in this release, at authorization before any consent dialog; a symlink to a share inside a requested path is found by walking the path with `lstat` and refused before it is followed (`src/main/local-realpath.ts`). + +## Windows + +The Windows build is a one-click, per-user NSIS installer (`Sim--x64.exe`), x64 only for now (arm64 later). It installs under `%LOCALAPPDATA%\Programs` without an elevation prompt, and uninstalling keeps the user's data (`deleteAppDataOnUninstall: false`). Supported: Windows 10 1809+ and Windows 11. + +- Local: `bun run package:win` (installer) or `bun run package:win:dir` (unpacked app in `release/win-unpacked/`), on Windows. `scripts/build-native.ts` skips the macOS/Linux native modules there, and `scripts/ensure-pty-prebuilds.ts` fetches the `win32-x64` ConPTY prebuild. `install:local` and `package:share` are macOS-only. +- Channel isolation: one-click installs derive their install directory and updater cache from the package **name**, not `productName`, so CI passes `-c.extraMetadata.name=` (`sim`, `sim-staging`, `sim-dev`, `sim-local`) beside `-c.productName`/`-c.appId`. `-c.appId` must match the AppUserModelID the app sets at runtime, or toasts and taskbar pins split. Local builds without the slug install as the package name. +- Icons: `build/icon*.ico` (16–256 px) are committed, generated from the per-channel `static/dock-icon*.png` by `bun run scripts/generate-windows-icons.ts` (needs the workspace's hoisted `sharp`); `scripts/build.ts` copies the channel's one to the ignored `build/generated-icon.ico`. +- Signing: Azure Artifact Signing through `win.azureSignOptions`, authenticated by GitHub OIDC (`azure/login`); nothing in `electron-builder.yml` or the workflow names the account. `signExts: [".node"]` also signs the node-pty addons, and `-c.forceCodeSigning=true` fails the build when no signing configuration is found (electron-builder 26 checks for the configuration, not each file's signature). `azureSignOptions.publisherName` is written into `resources/app-update.yml`; without it electron-updater **skips** signature verification of downloaded installers, so CI asserts it is present, and it must equal the certificate's subject CN exactly. Windows ASAR integrity is embedded in the exe by electron-builder, so the fuse policy carries over unchanged. +- Updates: the NSIS updater requests `latest.yml` from the same `/api/desktop/update` feed. `ready` is reached when electron-updater's own `update-downloaded` fires (after the publisher check); there is no native staging step. Restart runs the installer silently and relaunches; as on macOS it asks first only when it would interrupt running work (see Terminal below for what counts on Windows), and a required update never asks. Builds without a packaged `app-update.yml` fall back to the manual-download pipeline. Deltas use `.exe.blockmap`. +- Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. A Windows failure never blocks the macOS release, though the prerelease path waits for it to finish (`prerelease-publish` needs `prerelease-build-windows`), and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last. +- SmartScreen: a new signing identity starts without download reputation, so early installers can show "Windows protected your PC" until reputation builds. Keep the same certificate profile across releases. +- Terminal: runs PowerShell 7 (`pwsh.exe`) when it is on `Path`, else Windows PowerShell 5.1, else cmd, which has no shell integration, so the agent cannot run commands in it. `SHELL` set to a Windows path (`C:\Program Files\Git\bin\bash.exe`) selects Git Bash; its init file reads `/etc/profile` as a login shell would (for the MSYS `PATH`) and reports the cwd through `cygpath -w`. PowerShell starts with `-NoProfile -NoExit -EncodedCommand`: the script emits the startup marker, loads the user's profiles in PowerShell's order under their own execution policy, then wraps `prompt` and PSReadLine's `PSConsoleHostReadLine` to emit the OSC 633 markers (no PSReadLine, no integration). Windows has no process groups, so Stop sends Ctrl-C, then `taskkill /T /F` to each process the shell started since the command was typed, found among its descendants because a launcher can sit between the pty and the real shell; a process started earlier, such as a background server, is left running. An update restart asks first while an integrated shell is running a command, or, in cmd, while any process the shell started is still running. tmux and terminal themes are macOS and Linux only. `src/main/terminal/windows-shells.test.ts` drives PowerShell 7, Windows PowerShell 5.1 and Git Bash through ConPTY on the Windows CI leg. +- Native local-file tools: ship with the checks described in [Local filesystem access](#local-filesystem-access). +- Not available on Windows yet: native Computer Use, importing from other browsers, and **Continue in Chrome** / **Bring session back**. Copying a saved password is withheld because Clipboard History and the cloud clipboard keep it after Sim clears the clipboard; reveal still works. + +| Secret / variable | Kind | Contents | +|---|---|---| +| `AZURE_CLIENT_ID` | secret | App registration (or managed identity) client ID with a federated credential for this repository and the *Artifact Signing Certificate Profile Signer* role | +| `AZURE_TENANT_ID` | secret | Entra tenant ID | +| `AZURE_SUBSCRIPTION_ID` | secret | Subscription holding the signing account | +| `AZURE_SIGNING_ENDPOINT` | variable | Regional endpoint, e.g. `https://eus.codesigning.azure.net` | +| `AZURE_SIGNING_ACCOUNT` | variable | Artifact Signing account name | +| `AZURE_SIGNING_PROFILE` | variable | Certificate profile name | +| `AZURE_SIGNING_PUBLISHER` | variable | The certificate's subject CN, exactly (pinned in `app-update.yml`) | ## Auto-update, channels, rollout, rollback - `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Sim validates every candidate before starting its download. Developer ID builds installed under `/Applications` use a prompt (Restart and update / Later; Later installs on quit); other packaged builds offer a validated installer download — never forced mid-session. A staged or offered update keeps being re-checked on the normal cadence, and a newer release replaces it, so a shell left running across several releases installs the latest build in one restart instead of the stale one followed by another prompt. - Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration. -- Restart becomes available only after Squirrel confirms native staging. Replacing a staged update returns the UI to downloading; a failed transfer can retain the previous staged build, but a failure after the native feed is replaced requires a retry. Diagnostics record `update_downloaded` after native staging, `update_install` when an explicit restart is committed, and `update_install_result` on the next launch with the expected and installed versions. The staging checkpoint also covers updates installed on a normal quit. +- On macOS, restart becomes available only after Squirrel confirms native staging (Windows has no native step; see [Windows](#windows)). Replacing a staged update returns the UI to downloading; a failed transfer can retain the previous staged build, but a failure after the native feed is replaced requires a retry. Diagnostics record `update_downloaded` after native staging, `update_install` when an explicit restart is committed, and `update_install_result` on the next launch with the expected and installed versions. The staging checkpoint also covers updates installed on a normal quit. - `bun run test:e2e e2e/updater.spec.ts` exercises the real Electron process, MacUpdater, downloads, retries, and installation checkpoints. Native verification and bundle replacement are simulated. Set `DESKTOP_UPDATER_REPORT_PATH` to choose the JSON report path; by default it is included in Playwright's test results and uploaded by CI on failure. -- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean. +- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml` (and `latest.yml` for Windows), then raise as crash metrics stay clean. - Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.) - Ship the DMG and tell users to install to `/Applications` — App Translocation breaks Squirrel.Mac updates from quarantined paths. diff --git a/apps/desktop/build/icon-dev.ico b/apps/desktop/build/icon-dev.ico new file mode 100644 index 00000000000..ddb93b4348d Binary files /dev/null and b/apps/desktop/build/icon-dev.ico differ diff --git a/apps/desktop/build/icon-local.ico b/apps/desktop/build/icon-local.ico new file mode 100644 index 00000000000..d8653cf26b3 Binary files /dev/null and b/apps/desktop/build/icon-local.ico differ diff --git a/apps/desktop/build/icon-staging.ico b/apps/desktop/build/icon-staging.ico new file mode 100644 index 00000000000..ed35ea963d9 Binary files /dev/null and b/apps/desktop/build/icon-staging.ico differ diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 00000000000..e2dfb57cef1 Binary files /dev/null and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/e2e/fixtures/updater.ts b/apps/desktop/e2e/fixtures/updater.ts index a964dfcc979..ee53cfd691c 100644 --- a/apps/desktop/e2e/fixtures/updater.ts +++ b/apps/desktop/e2e/fixtures/updater.ts @@ -101,7 +101,7 @@ void app.whenReady().then(async () => { installStatePath: join(directory, 'update-install.json'), describeInFlightWork: async () => describeInFlightWork({ - terminalCommands, + terminalCommands: terminalCommands.map((command) => ({ command, shell: 'zsh' })), uncheckedTerminalCount: 0, computerUseApp: null, browserToolRunning, diff --git a/apps/desktop/e2e/packaged-smoke.spec.ts b/apps/desktop/e2e/packaged-smoke.spec.ts index 559db75d3ea..4ce89f3a72a 100644 --- a/apps/desktop/e2e/packaged-smoke.spec.ts +++ b/apps/desktop/e2e/packaged-smoke.spec.ts @@ -45,12 +45,43 @@ function profileWriters(appGroup: number, profilePaths: readonly string[]): numb }) } +const isWindows = process.platform === 'win32' + +/** + * Windows has no process groups. `taskkill /T` walks the tree from the main process — helpers and + * Crashpad's handler are its descendants — so it must run while the main process is still alive. + * Files stay locked for a moment after their process dies, so removal retries until it succeeds. + */ +async function stopAndRemoveWindowsProfile(child: ChildProcess, userDataPath: string) { + if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + try { + execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) + } catch {} + await exited + } + await expect + .poll(() => { + try { + rmSync(userDataPath, { recursive: true, force: true }) + return true + } catch { + return false + } + }) + .toBe(true) +} + /** * Removes a profile only once nothing can write into it. Killing the main process leaves its * helpers and Crashpad's handler running for a moment, and a file one of them creates while the * directory is being removed fails the removal with ENOTEMPTY. */ async function stopAndRemoveProfile(child: ChildProcess, userDataPath: string): Promise { + if (isWindows) { + await stopAndRemoveWindowsProfile(child, userDataPath) + return + } if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit') child.kill('SIGKILL') @@ -95,7 +126,8 @@ test('packaged main process starts and records launch telemetry', async () => { if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-')) const child = spawn(executablePath, [], { - detached: true, + // Its own process group on POSIX, for profileWriters; on Windows it would only open a console. + detached: !isWindows, env: { ...process.env, SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', @@ -140,7 +172,8 @@ test('packaged shell renders the bundled offline page', async () => { // run on a machine that already has the real app's item) would block on a // Keychain prompt on its main thread, and the debugging endpoint with it. const child = spawn(executablePath, ['--remote-debugging-port=0', '--use-mock-keychain'], { - detached: true, + // Its own process group on POSIX, for profileWriters; on Windows it would only open a console. + detached: !isWindows, env: { ...process.env, SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 5b303ac360b..dcae4c9448f 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -16,15 +16,11 @@ files: filter: - SeasonSansUprightsVF.woff2 -extraResources: - - from: dist/native/Sim Computer Use.app - to: Sim Computer Use.app - asar: true # Native modules cannot be dlopen'd from inside an asar. The Help-search addon -# is already universal; `scripts/ensure-pty-prebuilds.ts` guarantees both pty -# prebuilds are present before packaging. +# is already universal; `scripts/ensure-pty-prebuilds.ts` guarantees the host +# platform's pty prebuilds are present before packaging. asarUnpack: - "**/node_modules/@lydell/node-pty-*/prebuilds/**" - "**/dist/native/*.node" @@ -46,6 +42,12 @@ electronFuses: mac: category: public.app-category.developer-tools + # Each platform ships only its own node-pty prebuilds. + files: + - "!**/node_modules/@lydell/node-pty-win32-*/**" + extraResources: + - from: dist/native/Sim Computer Use.app + to: Sim Computer Use.app target: - target: dmg arch: [universal] @@ -98,6 +100,32 @@ dmg: type: link path: /Applications +# One-click per-user NSIS installer, x64 only for now. CI passes the channel's +# slug as -c.extraMetadata.name: one-click installs are keyed by the package +# name (install directory and updater cache), not productName, so without it +# every channel would install over the same directory. +win: + target: + - target: nsis + arch: [x64] + icon: build/generated-icon.ico + # .exe files are always signed; this also signs the native addons (node-pty). + # DLLs are not in signExts, so conpty.dll and Electron's own DLLs ship as + # their upstream builds left them, not with Sim's signature. + signExts: [".node"] + files: + - "!**/node_modules/@lydell/node-pty-darwin-*/**" + # ~10 MB of debug symbols nothing loads at runtime. + - "!**/node_modules/@lydell/node-pty-win32-*/prebuilds/**/*.pdb" + +nsis: + oneClick: true + perMachine: false + # One installer per architecture; never a combined multi-arch stub. + buildUniversalInstaller: false + # Uninstalling keeps the user's sessions, settings, and downloads. + deleteAppDataOnUninstall: false + # node-pty ships pure N-API prebuilds, which are ABI-stable across Node and # Electron versions, so there is nothing to rebuild against Electron's ABI. npmRebuild: false diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fe5088f6726..bc180400c6a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "license": "Apache-2.0", - "description": "Sim desktop app for macOS \u2014 Electron shell around the hosted web app", + "description": "Sim desktop app for macOS and Windows \u2014 Electron shell around the hosted web app", "author": "Sim ", "homepage": "https://sim.ai", "type": "module", @@ -18,6 +18,8 @@ "start": "electron .", "package:dir": "bun run build && electron-builder --mac dir --publish never", "package:mac": "bun run build && electron-builder --mac --publish never", + "package:win": "bun run build && electron-builder --win --x64 --publish never", + "package:win:dir": "bun run build && electron-builder --win dir --x64 --publish never", "package:share": "bun run scripts/package-share.ts", "install:local": "bun run scripts/install-local.ts", "type-check": "tsc --noEmit", @@ -31,8 +33,6 @@ }, "dependencies": { "@lydell/node-pty": "1.2.0-beta.12", - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", "@sim/logger": "workspace:*", @@ -48,6 +48,12 @@ "pdf-lib": "1.17.1", "@sim/auth-ui": "workspace:*" }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12" + }, "devDependencies": { "@electron/fuses": "1.8.0", "@playwright/test": "1.64.1", diff --git a/apps/desktop/scripts/build-native.ts b/apps/desktop/scripts/build-native.ts index eb0d3f1e99d..247ec7bd59e 100644 --- a/apps/desktop/scripts/build-native.ts +++ b/apps/desktop/scripts/build-native.ts @@ -13,6 +13,12 @@ const args = process.argv.slice(2) if (args.some((argument) => argument !== '--computer-use') || args.length > 1) { throw new Error('Usage: bun run scripts/build-native.ts [--computer-use]') } +if (process.platform === 'win32') { + // Windows opens local files through Node fs (win32-native-files.ts) and has + // no Help search, so neither module is needed. + logger.info('Skipping native desktop modules on Windows: local files use Node fs there') + process.exit(0) +} if (process.platform !== 'darwin' && process.platform !== 'linux') { throw new Error('Native desktop modules require macOS or Linux.') } diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index d8c74efdf14..05d06e72354 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -1,11 +1,15 @@ import { execFileSync } from 'node:child_process' import { cpSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type BuildOptions, build } from 'esbuild' import postcss from 'postcss' import loadPostcssConfig from 'postcss-load-config' import { identityForOrigin } from './channels' +const logger = createLogger('DesktopBuild') + const watch = process.argv.includes('--watch') // Optional build-time default server origin (pre-release shares pointed at a @@ -19,20 +23,21 @@ if ( !/^https:\/\/[^\s/]+$/.test(bakedDefaultOrigin) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(bakedDefaultOrigin) ) { - console.error( - `SIM_DESKTOP_DEFAULT_ORIGIN must be a bare https origin or http://localhost (got "${bakedDefaultOrigin}")` - ) + logger.error('SIM_DESKTOP_DEFAULT_ORIGIN must be a bare https origin or http://localhost', { + origin: bakedDefaultOrigin, + }) process.exit(1) } if (bakedDefaultOrigin) { - console.log(`• Baking default server origin: ${bakedDefaultOrigin}`) + logger.info('Baking default server origin', { origin: bakedDefaultOrigin }) } -const appIcon = identityForOrigin(bakedDefaultOrigin).icon +const identity = identityForOrigin(bakedDefaultOrigin) const generatedIcon = 'build/generated-icon.icon' rmSync(generatedIcon, { force: true, recursive: true }) -cpSync(appIcon, generatedIcon, { recursive: true }) -console.log(`• Selecting desktop icon: ${appIcon}`) +cpSync(identity.icon, generatedIcon, { recursive: true }) +cpSync(identity.windowsIcon, 'build/generated-icon.ico') +logger.info('Selecting desktop icon', { icon: identity.icon, windowsIcon: identity.windowsIcon }) const common = { bundle: true, @@ -161,6 +166,6 @@ async function run(): Promise { } run().catch((error) => { - console.error(error) + logger.error('Desktop build failed', { error: getErrorMessage(error) }) process.exit(1) }) diff --git a/apps/desktop/scripts/channels.ts b/apps/desktop/scripts/channels.ts index 1ff32b5ab0e..25fa0cfe7d6 100644 --- a/apps/desktop/scripts/channels.ts +++ b/apps/desktop/scripts/channels.ts @@ -1,15 +1,12 @@ /** - * Build-time channel identity, derived from the origin a build is baked to. + * Build-time channel identity, derived from the origin a build is baked to. The identity itself + * is `CHANNEL_IDENTITIES` (src/shared/channel-identity.ts), which the running app claims too. * - * Must stay in sync with APP_NAME_FOR_CHANNEL / channelForOrigin in - * src/main/config.ts. That pair is the RUNTIME source of truth — the app calls - * app.setName() from its baked origin at startup, which decides the userData - * directory and single-instance lock — and this is what the packager stamps - * into the bundle. When the two disagree, the bundle on disk and the running - * app disagree about which app they are: a dev-pointed build packaged as - * "Sim.app" with the production bundle id installs over a real production - * install while naming itself "Sim Dev" at runtime. + * {@link identityForOrigin} must stay in sync with channelForOrigin in src/main/config.ts: when + * the two disagree, a dev-pointed build packaged as "Sim.app" with the production bundle id + * installs over a real production install while naming itself "Sim Dev" at runtime. */ +import { CHANNEL_IDENTITIES, type DesktopChannelIdentity } from '@/shared/channel-identity' /** Canonical no-redirect origins per environment. */ export const PROD_ORIGIN = 'https://www.sim.ai' @@ -17,51 +14,16 @@ export const STAGING_ORIGIN = 'https://www.staging.sim.ai' export const DEV_ORIGIN = 'https://www.dev.sim.ai' export const LOCAL_ORIGIN = 'http://localhost:3000' -export interface ChannelIdentity { - /** Display + bundle name; also the userData directory name. */ - name: string - appId: string +/** A channel's identity, with the origin its builds are baked to. */ +export interface ChannelIdentity extends DesktopChannelIdentity { /** Baked default origin + persisted settings origin. */ origin: string - /** Native Icon Composer source copied into the packager's generated path. */ - icon: string - /** - * Artifact filename stem, and the per-channel scratch directory name. - * Space-free for the same reason electron-builder.yml's artifactName is: - * GitHub rewrites asset names containing spaces, which desyncs the - * electron-updater manifest from the uploaded files. - */ - slug: string } -export const PROD: ChannelIdentity = { - name: 'Sim', - appId: 'ai.sim.desktop', - origin: PROD_ORIGIN, - icon: 'build/icon.icon', - slug: 'sim', -} -export const STAGING: ChannelIdentity = { - name: 'Sim Staging', - appId: 'ai.sim.desktop.staging', - origin: STAGING_ORIGIN, - icon: 'build/icon-staging.icon', - slug: 'sim-staging', -} -export const DEV: ChannelIdentity = { - name: 'Sim Dev', - appId: 'ai.sim.desktop.dev', - origin: DEV_ORIGIN, - icon: 'build/icon-dev.icon', - slug: 'sim-dev', -} -export const LOCAL: ChannelIdentity = { - name: 'Sim Local', - appId: 'ai.sim.desktop.local', - origin: LOCAL_ORIGIN, - icon: 'build/icon-local.icon', - slug: 'sim-local', -} +export const PROD: ChannelIdentity = { ...CHANNEL_IDENTITIES.prod, origin: PROD_ORIGIN } +export const STAGING: ChannelIdentity = { ...CHANNEL_IDENTITIES.staging, origin: STAGING_ORIGIN } +export const DEV: ChannelIdentity = { ...CHANNEL_IDENTITIES.dev, origin: DEV_ORIGIN } +export const LOCAL: ChannelIdentity = { ...CHANNEL_IDENTITIES.local, origin: LOCAL_ORIGIN } /** Every channel, in the order a full run builds them. */ export const ALL_CHANNELS: readonly ChannelIdentity[] = [PROD, STAGING, DEV, LOCAL] diff --git a/apps/desktop/scripts/ensure-pty-prebuilds.ts b/apps/desktop/scripts/ensure-pty-prebuilds.ts index a08856a51f2..371ca6d2bb9 100644 --- a/apps/desktop/scripts/ensure-pty-prebuilds.ts +++ b/apps/desktop/scripts/ensure-pty-prebuilds.ts @@ -1,6 +1,7 @@ /** - * Fetches the node-pty prebuilt binaries for every architecture the macOS - * universal build ships. + * Fetches the node-pty prebuilt binaries for every architecture the host + * platform's package ships: both halves of the macOS universal build, or the + * x64 Windows build. * * `@lydell/node-pty` selects its native binary at runtime from a per-arch * package (`@lydell/node-pty-darwin-arm64`, `-darwin-x64`), each declaring a @@ -12,6 +13,11 @@ * Both binaries live at distinct paths, so `@electron/universal` never has to * lipo them together — it sees byte-identical trees in both halves and keeps * one. That is why this build needs no `x64ArchFiles` rule. + * + * Windows packages are x64-only for now. The Windows prebuild ships ConPTY instead of `pty.node`: + * `conpty.node`, `conpty_console_list.node` (which lists a console's processes when the pty + * exits), and the bundled `conpty/conpty.dll` and `conpty/OpenConsole.exe` that `useConptyDll` + * (set in `src/main/terminal/pty.ts`) runs the terminal on. */ import { execFileSync } from 'node:child_process' import { createHash, timingSafeEqual } from 'node:crypto' @@ -24,7 +30,6 @@ import { rmSync, writeFileSync, } from 'node:fs' -import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { createLogger } from '@sim/logger' @@ -32,7 +37,29 @@ import { getErrorMessage } from '@sim/utils/errors' const logger = createLogger('DesktopPtyPrebuilds') -const REQUIRED_ARCHES = ['darwin-arm64', 'darwin-x64'] as const +interface RequiredPrebuild { + arch: string + /** Every native file the PTY loads from `prebuilds//`; all present proves a full extract. */ + binaries: readonly string[] +} + +const REQUIRED_PREBUILDS: Partial> = { + darwin: [ + { arch: 'darwin-arm64', binaries: ['pty.node'] }, + { arch: 'darwin-x64', binaries: ['pty.node'] }, + ], + win32: [ + { + arch: 'win32-x64', + binaries: [ + 'conpty.node', + 'conpty_console_list.node', + 'conpty/conpty.dll', + 'conpty/OpenConsole.exe', + ], + }, + ], +} const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))) const workspaceRoot = dirname(dirname(desktopDir)) @@ -92,16 +119,18 @@ async function fetchPrebuild(arch: string, version: string): Promise { throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) } - const staging = mkdtempSync(join(tmpdir(), 'sim-pty-prebuild-')) + const target = packageDir(arch) + mkdirSync(dirname(target), { recursive: true }) + // Staged beside the target, not in the OS temp dir: a CI workspace on + // another drive (D:\a on Windows runners) would make the rename fail. + const staging = mkdtempSync(join(dirname(target), '.sim-pty-prebuild-')) try { const tarball = join(staging, 'package.tgz') const bytes = Buffer.from(await response.arrayBuffer()) verifyIntegrity(bytes, expectedIntegrity(arch, version), packageName) writeFileSync(tarball, bytes) - execFileSync('tar', ['-xzf', tarball, '-C', staging], { stdio: 'pipe' }) + execFileSync(tarExecutable(), ['-xzf', tarball, '-C', staging], { stdio: 'pipe' }) - const target = packageDir(arch) - mkdirSync(dirname(target), { recursive: true }) rmSync(target, { recursive: true, force: true }) renameSync(join(staging, 'package'), target) } finally { @@ -109,18 +138,38 @@ async function fetchPrebuild(arch: string, version: string): Promise { } } +/** + * Windows' own bsdtar. Under Git Bash, a bare `tar` resolves to GNU tar, which + * reads the `C:` in an absolute path as a remote host. + */ +function tarExecutable(): string { + if (process.platform !== 'win32') return 'tar' + return join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') +} + async function run(): Promise { + const required = REQUIRED_PREBUILDS[process.platform] + if (!required) { + logger.info('No packaged node-pty prebuilds for this platform', { + platform: process.platform, + }) + return + } const version = await pinnedVersion() - for (const arch of REQUIRED_ARCHES) { - const dir = packageDir(arch) - if (existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) { + for (const { arch, binaries } of required) { + const missing = () => + binaries.filter((binary) => !existsSync(join(packageDir(arch), 'prebuilds', arch, binary))) + if (missing().length === 0) { logger.info('node-pty prebuild present', { arch }) continue } logger.info('Fetching node-pty prebuild', { arch, version }) await fetchPrebuild(arch, version) - if (!existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) { - throw new Error(`Downloaded @lydell/node-pty-${arch} but pty.node is missing`) + const stillMissing = missing() + if (stillMissing.length > 0) { + throw new Error( + `Downloaded @lydell/node-pty-${arch} but ${stillMissing.join(', ')} is missing` + ) } } } diff --git a/apps/desktop/scripts/generate-windows-icons.ts b/apps/desktop/scripts/generate-windows-icons.ts new file mode 100644 index 00000000000..d726759a205 --- /dev/null +++ b/apps/desktop/scripts/generate-windows-icons.ts @@ -0,0 +1,128 @@ +/** + * Regenerates the committed Windows icons (`build/icon*.ico`) from the + * per-channel 512px dock icons in `static/`, which already carry each + * channel's outline color. + * + * Run from `apps/desktop` after a dock icon changes: + * + * bun run scripts/generate-windows-icons.ts + * + * The output is committed rather than generated per build so packaging needs + * no image toolchain on the Windows runner. `sharp` resolves from the + * workspace root, where `apps/sim` hoists it. + * + * Each `.ico` holds 16–64px frames as 32-bit BMPs and the 256px frame as PNG — + * the layout Explorer, the taskbar, resedit, and NSIS all read. The macOS + * squircle's transparent margin is trimmed so the shape fills the Windows + * icon grid instead of rendering visibly smaller than neighboring apps. + */ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import sharp from 'sharp' +import { ALL_CHANNELS } from './channels' + +const logger = createLogger('DesktopWindowsIcons') + +const FRAME_SIZES = [16, 24, 32, 48, 64, 256] as const +/** Frames at or above this size are stored as PNG; smaller ones as BMP. */ +const PNG_FRAME_MIN_SIZE = 256 +/** Breathing room around the trimmed squircle, as a fraction of the frame. */ +const FRAME_PADDING = 0.04 + +const ICONDIR_BYTES = 6 +const ICONDIRENTRY_BYTES = 16 +const BITMAPINFOHEADER_BYTES = 40 + +interface IconFrame { + size: number + data: Buffer +} + +async function renderFrame(trimmed: Buffer, size: number): Promise { + const inner = Math.max(1, Math.round(size * (1 - FRAME_PADDING * 2))) + const offset = Math.floor((size - inner) / 2) + const resized = sharp(trimmed) + .resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .extend({ + top: offset, + left: offset, + bottom: size - inner - offset, + right: size - inner - offset, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + if (size >= PNG_FRAME_MIN_SIZE) { + return { size, data: await resized.png({ compressionLevel: 9 }).toBuffer() } + } + const rgba = await resized.ensureAlpha().raw().toBuffer() + return { size, data: encodeBmpFrame(rgba, size) } +} + +/** + * A 32bpp bottom-up DIB with the doubled height ICO frames declare, followed by + * an all-zero AND mask (alpha already carries transparency). + */ +function encodeBmpFrame(rgba: Buffer, size: number): Buffer { + const maskRowBytes = Math.ceil(size / 32) * 4 + const pixelBytes = size * size * 4 + const frame = Buffer.alloc(BITMAPINFOHEADER_BYTES + pixelBytes + maskRowBytes * size) + frame.writeUInt32LE(BITMAPINFOHEADER_BYTES, 0) + frame.writeInt32LE(size, 4) + frame.writeInt32LE(size * 2, 8) + frame.writeUInt16LE(1, 12) + frame.writeUInt16LE(32, 14) + frame.writeUInt32LE(0, 16) + frame.writeUInt32LE(pixelBytes, 20) + for (let y = 0; y < size; y++) { + const sourceRow = (size - 1 - y) * size * 4 + const targetRow = BITMAPINFOHEADER_BYTES + y * size * 4 + for (let x = 0; x < size; x++) { + const source = sourceRow + x * 4 + const target = targetRow + x * 4 + frame[target] = rgba[source + 2] + frame[target + 1] = rgba[source + 1] + frame[target + 2] = rgba[source] + frame[target + 3] = rgba[source + 3] + } + } + return frame +} + +function encodeIco(frames: readonly IconFrame[]): Buffer { + const header = Buffer.alloc(ICONDIR_BYTES + ICONDIRENTRY_BYTES * frames.length) + header.writeUInt16LE(0, 0) + header.writeUInt16LE(1, 2) + header.writeUInt16LE(frames.length, 4) + let offset = header.length + frames.forEach((frame, index) => { + const entry = ICONDIR_BYTES + ICONDIRENTRY_BYTES * index + // A 256px dimension is encoded as 0. + header.writeUInt8(frame.size >= 256 ? 0 : frame.size, entry) + header.writeUInt8(frame.size >= 256 ? 0 : frame.size, entry + 1) + header.writeUInt8(0, entry + 2) + header.writeUInt8(0, entry + 3) + header.writeUInt16LE(1, entry + 4) + header.writeUInt16LE(32, entry + 6) + header.writeUInt32LE(frame.data.length, entry + 8) + header.writeUInt32LE(offset, entry + 12) + offset += frame.data.length + }) + return Buffer.concat([header, ...frames.map((frame) => frame.data)]) +} + +async function run(): Promise { + for (const channel of ALL_CHANNELS) { + const trimmed = await sharp(join('static', channel.dockIcon)).trim().png().toBuffer() + const frames: IconFrame[] = [] + for (const size of FRAME_SIZES) { + frames.push(await renderFrame(trimmed, size)) + } + writeFileSync(channel.windowsIcon, encodeIco(frames)) + logger.info('Wrote Windows icon', { channel: channel.slug, path: channel.windowsIcon }) + } +} + +run().catch((error) => { + logger.error('Could not generate Windows icons', { error }) + process.exit(1) +}) diff --git a/apps/desktop/scripts/install-local.ts b/apps/desktop/scripts/install-local.ts index b320c708cbb..fd0388f2d28 100644 --- a/apps/desktop/scripts/install-local.ts +++ b/apps/desktop/scripts/install-local.ts @@ -22,32 +22,34 @@ import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' +import { createLogger } from '@sim/logger' +import { CHANNEL_IDENTITIES, type DesktopChannelIdentity } from '@/shared/channel-identity' +import { DEV, LOCAL, STAGING } from './channels' + +if (process.platform !== 'darwin') { + createLogger('DesktopInstallLocal').error( + 'install:local installs into /Applications and only runs on macOS. On Windows, run `bun run package:win` and launch release/Sim--x64.exe.' + ) + process.exit(1) +} -interface ChannelIdentity { - /** Display + bundle name; also the userData directory name. */ - name: string - appId: string +interface InstallIdentity extends DesktopChannelIdentity { /** Baked default origin + persisted settings origin. Unset = leave as-is. */ origin?: string } -/** Must stay in sync with APP_NAME_FOR_CHANNEL in src/main/config.ts. */ -const CHANNEL_FLAGS: Record = { - '--local': { name: 'Sim Local', appId: 'ai.sim.desktop.local', origin: 'http://localhost:3000' }, - '--dev': { name: 'Sim Dev', appId: 'ai.sim.desktop.dev', origin: 'https://www.dev.sim.ai' }, - '--staging': { - name: 'Sim Staging', - appId: 'ai.sim.desktop.staging', - origin: 'https://www.staging.sim.ai', - }, +const CHANNEL_FLAGS: Record = { + '--local': LOCAL, + '--dev': DEV, + '--staging': STAGING, // Origin left unset, exactly as official prod builds do, so this install // resolves the same DEFAULT_ORIGIN (config.ts) and therefore the same cookie // partition as a real one. Naming an origin here re-creates the drift that // pinned prod to the apex while the server serves www. - '--prod': { name: 'Sim', appId: 'ai.sim.desktop' }, + '--prod': CHANNEL_IDENTITIES.prod, } -const DEFAULT_IDENTITY: ChannelIdentity = { name: 'Sim', appId: 'ai.sim.desktop' } +const DEFAULT_IDENTITY: InstallIdentity = CHANNEL_IDENTITIES.prod const channelFlags = process.argv.filter((arg) => arg in CHANNEL_FLAGS) if (channelFlags.length > 1) { diff --git a/apps/desktop/scripts/package-share.ts b/apps/desktop/scripts/package-share.ts index 3700c866171..6bc6ae2c367 100644 --- a/apps/desktop/scripts/package-share.ts +++ b/apps/desktop/scripts/package-share.ts @@ -35,8 +35,16 @@ */ import { spawnSync } from 'node:child_process' import { rmSync } from 'node:fs' +import { createLogger } from '@sim/logger' import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels' +if (process.platform !== 'darwin') { + createLogger('DesktopPackageShare').error( + 'package:share builds macOS dmg/zip artifacts and only runs on macOS. On Windows, use `bun run package:win`.' + ) + process.exit(1) +} + const FLAG_TO_CHANNEL: Record = Object.fromEntries( ALL_CHANNELS.map((channel) => [ `--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`, diff --git a/apps/desktop/src/main/atomic-json-file.test.ts b/apps/desktop/src/main/atomic-json-file.test.ts index b95236083ab..b6583c2ecb2 100644 --- a/apps/desktop/src/main/atomic-json-file.test.ts +++ b/apps/desktop/src/main/atomic-json-file.test.ts @@ -1,11 +1,13 @@ import { 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FileResourceLimitError, readFileWithinLimit, readFileWithinLimitSync, + renameReplacing, + renameReplacingSync, } from '@/main/atomic-json-file' describe('bounded file reads', () => { @@ -27,3 +29,53 @@ describe('bounded file reads', () => { expect(() => readFileWithinLimitSync(filePath, 8)).toThrow(FileResourceLimitError) }) }) + +describe.each([ + { + name: 'renameReplacing', + run: (rename: () => void, platform: NodeJS.Platform) => + renameReplacing('from', 'to', { platform, rename: async () => rename() }), + }, + { + name: 'renameReplacingSync', + run: async (rename: () => void, platform: NodeJS.Platform) => + renameReplacingSync('from', 'to', { platform, rename }), + }, +])('$name', ({ run }) => { + const busy = (code: string) => Object.assign(new Error(code), { code }) + + function flakyRename(failures: number, code = 'EBUSY') { + let calls = 0 + return vi.fn(() => { + calls += 1 + if (calls <= failures) throw busy(code) + }) + } + + it.each(['EPERM', 'EBUSY', 'EACCES'])( + 'retries a Windows rename that fails with %s until the holder lets go', + async (code) => { + const rename = flakyRename(2, code) + await run(rename, 'win32') + expect(rename).toHaveBeenCalledTimes(3) + } + ) + + it('gives up on Windows after a bounded number of attempts', async () => { + const rename = flakyRename(Number.POSITIVE_INFINITY) + await expect(run(rename, 'win32')).rejects.toThrow('EBUSY') + expect(rename).toHaveBeenCalledTimes(5) + }) + + it('does not retry an error a held file cannot cause', async () => { + const rename = flakyRename(1, 'ENOENT') + await expect(run(rename, 'win32')).rejects.toThrow('ENOENT') + expect(rename).toHaveBeenCalledTimes(1) + }) + + it('does not retry on macOS, where these codes mean a real permission failure', async () => { + const rename = flakyRename(1, 'EACCES') + await expect(run(rename, 'darwin')).rejects.toThrow('EACCES') + expect(rename).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/main/atomic-json-file.ts b/apps/desktop/src/main/atomic-json-file.ts index ff364e2dc40..d7cc4bcad0c 100644 --- a/apps/desktop/src/main/atomic-json-file.ts +++ b/apps/desktop/src/main/atomic-json-file.ts @@ -9,6 +9,8 @@ import { } from 'node:fs' import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' /** Owner-only, matching every store that keeps user data in userData. */ const FILE_MODE = 0o600 @@ -84,6 +86,69 @@ function temporaryPathFor(filePath: string): string { return `${filePath}.${process.pid}.${temporaryFileCounter}.tmp` } +/** Defender and the search indexer briefly hold files open on Windows, which fails the rename. */ +const WINDOWS_TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']) +const MAX_RENAME_ATTEMPTS = 5 + +interface RenameOptions { + platform?: NodeJS.Platform + rename?: (from: string, to: string) => Result +} + +function shouldRetryRename(error: unknown, attempt: number, platform: NodeJS.Platform): boolean { + const code = (error as NodeJS.ErrnoException).code + return ( + platform === 'win32' && + attempt < MAX_RENAME_ATTEMPTS && + code !== undefined && + WINDOWS_TRANSIENT_RENAME_CODES.has(code) + ) +} + +function renameRetryDelayMs(attempt: number): number { + return backoffWithJitter(attempt, null, { baseMs: 20, maxMs: 200 }) +} + +/** + * Renames over `to`, retrying a few times on Windows while another process + * holds the target. Other platforms and other errors fail on the first try. + */ +export async function renameReplacing( + from: string, + to: string, + { platform = process.platform, rename: renameFile = rename }: RenameOptions> = {} +): Promise { + for (let attempt = 1; ; attempt++) { + try { + await renameFile(from, to) + return + } catch (error) { + if (!shouldRetryRename(error, attempt, platform)) throw error + await sleep(renameRetryDelayMs(attempt)) + } + } +} + +/** + * {@link renameReplacing} for a caller that cannot await. The waits block the + * thread, bounded by the same few short attempts. + */ +export function renameReplacingSync( + from: string, + to: string, + { platform = process.platform, rename: renameFile = renameSync }: RenameOptions = {} +): void { + for (let attempt = 1; ; attempt++) { + try { + renameFile(from, to) + return + } catch (error) { + if (!shouldRetryRename(error, attempt, platform)) throw error + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, renameRetryDelayMs(attempt)) + } + } +} + /** * Crash-safe JSON writes for the small encrypted stores in userData. * @@ -97,7 +162,7 @@ export async function writeJsonFileAtomically(filePath: string, value: unknown): await mkdir(dirname(filePath), { recursive: true }) const temporaryPath = temporaryPathFor(filePath) await writeFile(temporaryPath, JSON.stringify(value), { mode: FILE_MODE }) - await rename(temporaryPath, filePath) + await renameReplacing(temporaryPath, filePath) } /** @@ -115,7 +180,7 @@ export function writeJsonFileAtomicallySync( mkdirSync(dirname(filePath), { recursive: true }) const temporaryPath = temporaryPathFor(filePath) writeFileSync(temporaryPath, JSON.stringify(value, null, indent), { mode: FILE_MODE }) - renameSync(temporaryPath, filePath) + renameReplacingSync(temporaryPath, filePath) } /** diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 7c3ce842eaa..ca02cc081c2 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1,7 +1,7 @@ import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol' import { toRecord } from '@sim/utils/object' import type { WebContents } from 'electron' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -15,7 +15,7 @@ vi.mock('@/main/browser-agent/file-transfer', () => ({ discardStagedUploads: vi.fn(async () => {}), })) -import { BrowserWindow, type nativeImage } from 'electron' +import { BrowserWindow, Menu, type nativeImage } from 'electron' import * as cdp from '@/main/browser-agent/cdp' import * as driverModule from '@/main/browser-agent/driver' import * as session from '@/main/browser-agent/session' @@ -3707,3 +3707,25 @@ describe('credential protection', () => { expect(result.error).toMatch(/page changed while its screenshot was being captured/) }) }) + +describe('showToolbarMenu', () => { + const realPlatform = process.platform + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) + }) + + it.each([ + ['darwin', true], + ['win32', false], + ] as const)( + 'lists Import Passwords on %s only where browser import ships (%s)', + (platform, offered) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + freshDriver().showToolbarMenu('chat-test', new BrowserWindow(), { x: 0, y: 0 }) + + const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] ?? [] + expect(template.some((item) => item.label === 'Import Passwords')).toBe(offered) + } + ) +}) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 5a35e1d789e..72fe6a6a13c 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -98,6 +98,7 @@ import { checkAgentUrl } from '@/main/browser-agent/url-guard' import { clearCredentials, fillCoordinator, initFillCoordinator } from '@/main/browser-credentials' import type { ConfigStore } from '@/main/config' import { trackInputActivity } from '@/main/input-activity' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('BrowserAgentDriver') @@ -932,7 +933,9 @@ export function showToolbarMenu( }, }, { label: 'Passwords', click: () => sendCommand('passwords') }, - { label: 'Import Passwords', click: () => sendCommand('import') }, + ...(desktopFeatures(process.platform).browserImport + ? [{ label: 'Import Passwords', click: () => sendCommand('import') }] + : []), { type: 'separator' }, { label: 'Browser Settings', click: () => sendCommand('browser-settings') }, ] diff --git a/apps/desktop/src/main/browser-agent/file-transfer.test.ts b/apps/desktop/src/main/browser-agent/file-transfer.test.ts index bc4569f37c6..1454fd715a7 100644 --- a/apps/desktop/src/main/browser-agent/file-transfer.test.ts +++ b/apps/desktop/src/main/browser-agent/file-transfer.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { open, rename, rm, truncate } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { BROWSER_FILE_TRANSFER_MAX_BYTES } from '@sim/browser-protocol' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -59,7 +59,7 @@ describe('stageUploadFiles', () => { body: JSON.stringify({ toolCallId: 'call-1', index: 0 }), signal, }) - expect(staged.map((path) => path.split('/').pop())).toEqual(['Q3 plan.pdf', 'granted.txt']) + expect(staged.map((path) => basename(path))).toEqual(['Q3 plan.pdf', 'granted.txt']) expect(readFileSync(staged[0], 'utf8')).toBe('workspace bytes') expect(readFileSync(staged[1], 'utf8')).toBe('local bytes') expect(staged.every((path) => path.startsWith(join(temp, 'sim-browser-uploads')))).toBe(true) @@ -83,7 +83,7 @@ describe('stageUploadFiles', () => { }) expect(staged.startsWith(join(temp, 'sim-browser-uploads'))).toBe(true) - expect(staged.endsWith('/evil')).toBe(true) + expect(basename(staged)).toBe('evil') }) it('refuses a workspace file over the transfer ceiling', async () => { diff --git a/apps/desktop/src/main/browser-agent/file-transfer.ts b/apps/desktop/src/main/browser-agent/file-transfer.ts index ddc72c6b2da..009035bdfc6 100644 --- a/apps/desktop/src/main/browser-agent/file-transfer.ts +++ b/apps/desktop/src/main/browser-agent/file-transfer.ts @@ -18,6 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { app } from 'electron' import { ToolError } from '@/main/browser-agent/errors' import type { BrowserAppSession } from '@/main/browser-agent/session' +import { toWin32SafeFileName } from '@/main/win32-path' /** Granted local folders; the caller owns and must close each returned file handle. */ export interface LocalFileSource { @@ -39,12 +40,14 @@ function directoryName(value: string): string { return encodeURIComponent(value).replaceAll('.', '%2E') } -/** A file name that cannot leave the staging directory. */ +/** A file name that cannot leave the staging directory, and that Windows stores as spelled. */ function stagedFileName(name: string): string { const base = basename(name) .replace(/[\x00-\x1f\x7f]/g, '') .trim() - return base && base !== '.' && base !== '..' ? base.slice(0, 200) : 'upload' + .slice(0, 200) + const safe = process.platform === 'win32' ? toWin32SafeFileName(base) : base + return safe && safe !== '.' && safe !== '..' ? safe : 'upload' } function filenameFromDisposition(header: string | null): string | null { diff --git a/apps/desktop/src/main/browser-agent/keyboard.test.ts b/apps/desktop/src/main/browser-agent/keyboard.test.ts index 18c98710645..9c8e0e3d651 100644 --- a/apps/desktop/src/main/browser-agent/keyboard.test.ts +++ b/apps/desktop/src/main/browser-agent/keyboard.test.ts @@ -5,6 +5,7 @@ vi.mock('electron', () => import('@/test/electron-mock')) import { WebContentsView } from 'electron' import { buildKeyDispatchPlan, + comboTouchesClipboard, dispatchKeyCombo, KeyDispatchError, modifierKeyEvents, @@ -101,6 +102,12 @@ describe('buildKeyDispatchPlan', () => { expect(linuxDown.commands).toBeUndefined() }) + it('sends a Cmd editing shortcut as Ctrl on Windows instead of the Windows key', () => { + const [down] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Z', 'win32'), 'win32') + expect(down.modifiers).toBe(2) + expect(down.commands).toBeUndefined() + }) + it('encodes the CDP modifier bitmask (Alt=1 Ctrl=2 Meta=4 Shift=8)', () => { const [down] = buildKeyDispatchPlan(parseKeyCombo('Control+Shift+K'), 'linux') expect(down.modifiers).toBe(2 | 8) @@ -110,6 +117,19 @@ describe('buildKeyDispatchPlan', () => { }) }) +describe('comboTouchesClipboard', () => { + it.each(['Shift+Insert', 'Control+Insert', 'Shift+Delete'])( + 'refuses %s on Windows, where Blink binds it to paste, copy, or cut', + (key) => { + expect(comboTouchesClipboard(parseKeyCombo(key, 'win32'), 'win32')).toBe(true) + } + ) + + it.each(['Insert', 'Delete', 'Control+Delete'])('leaves %s alone on Windows', (key) => { + expect(comboTouchesClipboard(parseKeyCombo(key, 'win32'), 'win32')).toBe(false) + }) +}) + describe('modifierKeyEvents', () => { it('presses each chord modifier in order and releases them in reverse', () => { const { downs, ups } = modifierKeyEvents(parseKeyCombo('Control+Shift+Y', 'linux'), 'linux') @@ -169,7 +189,7 @@ describe('dispatchKeyCombo', () => { .mockRejectedValueOnce(new Error('key-up response lost')) .mockRejectedValueOnce(new Error('cleanup unavailable')) - const dispatch = dispatchKeyCombo(contents, parseKeyCombo('Cmd+A')) + const dispatch = dispatchKeyCombo(contents, parseKeyCombo('Mod+A')) await expect(dispatch).rejects.toEqual( expect.objectContaining({ @@ -182,7 +202,7 @@ describe('dispatchKeyCombo', () => { const keyEvents = vi .mocked(contents.debugger.sendCommand) .mock.calls.filter(([method]) => method === 'Input.dispatchKeyEvent') - // Meta down, A down, A up (lost), then cleanup re-releases A and releases the held Meta. + // Mod down, A down, A up (lost), then cleanup re-releases A and releases the held Mod. expect(keyEvents).toHaveLength(5) expect(keyEvents[2]).toEqual(keyEvents[3]) expect(keyEvents[2]).toEqual([ @@ -191,7 +211,10 @@ describe('dispatchKeyCombo', () => { ]) expect(keyEvents[4]).toEqual([ 'Input.dispatchKeyEvent', - expect.objectContaining({ type: 'keyUp', key: 'Meta' }), + expect.objectContaining({ + type: 'keyUp', + key: process.platform === 'darwin' ? 'Meta' : 'Control', + }), ]) expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(1, true) expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(2, false) diff --git a/apps/desktop/src/main/browser-agent/keyboard.ts b/apps/desktop/src/main/browser-agent/keyboard.ts index ed755763b81..f7d9960e108 100644 --- a/apps/desktop/src/main/browser-agent/keyboard.ts +++ b/apps/desktop/src/main/browser-agent/keyboard.ts @@ -251,9 +251,18 @@ function editingCommandFor(combo: ParsedCombo): string | null { /** * Whether a combo would drive the system clipboard. Checked before dispatch * rather than by withholding the CDP `commands` array: off macOS these are - * Blink-native, so a key event alone still performs them. + * Blink-native, so a key event alone still performs them. That includes + * Blink's legacy bindings there: Shift+Insert pastes, Ctrl+Insert copies, + * and Shift+Delete cuts. */ -export function comboTouchesClipboard(combo: ParsedCombo): boolean { +export function comboTouchesClipboard( + combo: ParsedCombo, + platform: NodeJS.Platform = process.platform +): boolean { + if (platform !== 'darwin') { + if (combo.key === 'Insert' && (combo.shift || combo.ctrl)) return true + if (combo.key === 'Delete' && combo.shift) return true + } if (!combo.ctrl && !combo.meta) return false const key = combo.key.toLowerCase() return key === 'v' || key === 'c' || key === 'x' @@ -265,13 +274,13 @@ export function comboTouchesClipboard(combo: ParsedCombo): boolean { * explicitly (same technique as Puppeteer/Playwright). The model doesn't know * the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd * for these shortcuts: both must select all, not silently no-op. On other - * platforms Ctrl+key is handled inside Blink and needs no help. + * platforms Ctrl+key is handled inside Blink and needs no help, so a Cmd+A + * the model meant for a Mac becomes Ctrl+A there instead of the Windows key. */ function normalizeComboForPlatform(combo: ParsedCombo, platform: NodeJS.Platform): ParsedCombo { - if (platform !== 'darwin' || !combo.ctrl || combo.meta || editingCommandFor(combo) === null) { - return combo - } - return { ...combo, ctrl: false, meta: true } + if (editingCommandFor(combo) === null || combo.ctrl === combo.meta) return combo + if (platform === 'darwin') return combo.ctrl ? { ...combo, ctrl: false, meta: true } : combo + return combo.meta ? { ...combo, ctrl: true, meta: false } : combo } function macEditingCommands(combo: ParsedCombo, platform: NodeJS.Platform): string[] { diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 6f1ceff17b0..869d52e35a2 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -2451,3 +2451,33 @@ describe('first-party browser sessions', () => { expect(routeAgentNavigation(contents, `${origin}/submit`, 'POST')).toBe(false) }) }) + +describe('browserShortcutForInput', () => { + const keyDown = { + type: 'keyDown', + key: 'Tab', + code: 'Tab', + isAutoRepeat: false, + isComposing: false, + shift: false, + control: false, + alt: false, + meta: false, + } as const + + it.each(['darwin', 'win32'] as const)( + 'cycles tabs with Ctrl+Tab on %s, as Chrome does', + (platform) => { + expect(sessionModule.browserShortcutForInput({ ...keyDown, control: true }, platform)).toBe( + 'next-tab' + ) + expect( + sessionModule.browserShortcutForInput({ ...keyDown, control: true, shift: true }, platform) + ).toBe('previous-tab') + expect(sessionModule.browserShortcutForInput(keyDown, platform)).toBeNull() + expect( + sessionModule.browserShortcutForInput({ ...keyDown, control: true, meta: true }, platform) + ).toBeNull() + } + ) +}) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index e912baa5722..fcf5b1f043a 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -287,7 +287,8 @@ type BrowserShortcutInput = Pick< * wrong guess reloads all of Sim instead of the page. * * `stop` is any bare Escape; only a loading page acts on it, and the page - * still receives the key. + * still receives the key. Ctrl+Tab cycles tabs with Control on macOS too, as + * Chrome does. */ export function browserShortcutForInput( input: BrowserShortcutInput, @@ -311,6 +312,9 @@ export function browserShortcutForInput( return arrow === 'left' ? 'back' : 'forward' } if (input.key === 'Escape' && !input.shift && !input.control && !input.meta) return 'stop' + if (input.key === 'Tab' && input.control && !input.meta) { + return input.shift ? 'previous-tab' : 'next-tab' + } if (input.key === 'F5' && !otherModifier) { if (input.shift || input.control) return 'hard-reload' return input.meta ? null : 'reload' diff --git a/apps/desktop/src/main/browser-credentials/index.ts b/apps/desktop/src/main/browser-credentials/index.ts index e78086e4d2d..bde2618e1ae 100644 --- a/apps/desktop/src/main/browser-credentials/index.ts +++ b/apps/desktop/src/main/browser-credentials/index.ts @@ -104,6 +104,8 @@ export async function revealCredential(id: string): Promise { * * The clipboard is only cleared if it still holds this password — overwriting * whatever the user copied in the meantime would be its own small betrayal. + * Where clearing cannot reach every copy (`DesktopFeatures.passwordCopy`), its IPC channel is + * never registered. */ export async function copyCredential(id: string): Promise { const authorized = await authorizeForSecret({ diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts index 2aea35c5c0b..0c90d683326 100644 --- a/apps/desktop/src/main/browser-credentials/os-auth.ts +++ b/apps/desktop/src/main/browser-credentials/os-auth.ts @@ -22,10 +22,11 @@ const AUTH_GRACE_MS = 30_000 * * Neither operation dominates the other, so a grant satisfies only its own. * `reveal` hands the plaintext to the Sim renderer; `copy` publishes it to the - * macOS pasteboard, which every process on the machine can read, which + * system clipboard, which every process on the machine can read, which * clipboard managers persist to disk beyond the 30s `clipboard.clear()`, and * which Universal Clipboard syncs to the user's other devices. Ordering them * either way lets one consent authorize an exposure the prompt never described. + * Windows does not offer copy at all: Clipboard History keeps the text. */ export type SecretOperation = 'reveal' | 'copy' @@ -96,8 +97,8 @@ export function revokeSecretAuthorization(credentialId?: string): void { * renderer cannot drive or fake. * * Touch ID is used when the machine has it. Where it does not (a Mac mini - * without a Touch ID keyboard, or a user who has not enrolled), the fallback - * is a native modal that the main process owns. That is genuinely weaker than + * without a Touch ID keyboard, a user who has not enrolled, or any Windows + * machine), the fallback is a native modal that the main process owns. That is genuinely weaker than * biometric auth — it proves presence, not identity — so it is a deliberate, * documented trade rather than a silent one, and it still cannot be triggered * without a real click in the page. diff --git a/apps/desktop/src/main/browser-credentials/picker.ts b/apps/desktop/src/main/browser-credentials/picker.ts index e00d062a82a..49101b91a98 100644 --- a/apps/desktop/src/main/browser-credentials/picker.ts +++ b/apps/desktop/src/main/browser-credentials/picker.ts @@ -61,6 +61,8 @@ export class CredentialPicker { app.isPackaged ), }) + // Windows attaches the app menu to every window; its accelerators would act inside the picker. + if (process.platform === 'win32') win.removeMenu() this.window = win const pageUrl = localPageUrl('credential-picker.html') attachShellTheme(win) diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts index 264bbe7f799..03ac1ec231f 100644 --- a/apps/desktop/src/main/browser-credentials/vault.test.ts +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -86,7 +86,7 @@ describe('CredentialVault', () => { expect(JSON.parse(onDisk)).toMatchObject({ version: 1, ciphertext: expect.any(String) }) }) - it('writes the vault file owner-only', async () => { + it.skipIf(process.platform === 'win32')('writes the vault file owner-only', async () => { const vault = new CredentialVault(vaultPath, encryption()) await vault.importCredentials(CANDIDATES, 'keep-existing') diff --git a/apps/desktop/src/main/browser-credentials/vault.ts b/apps/desktop/src/main/browser-credentials/vault.ts index 08b6fe0a5f0..040e8620436 100644 --- a/apps/desktop/src/main/browser-credentials/vault.ts +++ b/apps/desktop/src/main/browser-credentials/vault.ts @@ -14,8 +14,9 @@ import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/o * The encrypted credential store. * * The whole record set is encrypted as one blob with Electron `safeStorage` - * (Keychain on macOS), written atomically with restrictive permissions. There - * is no plaintext fallback: when OS-backed encryption is unavailable the vault + * (Keychain on macOS, DPAPI on Windows) and written atomically. On macOS the file is owner-only + * (0600); Windows ignores POSIX modes, so there the protection is DPAPI's per-user key plus the + * ACL on the user's profile folder. There is no plaintext fallback: when OS-backed encryption is unavailable the vault * reports itself unavailable and refuses to store anything, because a password * file Sim cannot encrypt is worse than a feature the user does not get. * diff --git a/apps/desktop/src/main/browser-sites/directory.ts b/apps/desktop/src/main/browser-sites/directory.ts index 994b5e79585..3805eb1cdcb 100644 --- a/apps/desktop/src/main/browser-sites/directory.ts +++ b/apps/desktop/src/main/browser-sites/directory.ts @@ -17,10 +17,10 @@ import { * anyway. It is a name and an icon per host — never a visit log, never a URL * beyond the host itself. * - * Encrypted with the same OS-backed key as the credential vault. The hostnames - * in here include ones only a saved password knows about, and those live in an - * encrypted vault today; writing them to a plaintext file beside it would - * quietly undo that. + * Encrypted with the same OS-backed `safeStorage` key as the credential vault + * (Keychain on macOS, DPAPI on Windows). The hostnames in here include ones + * only a saved password knows about, and those live in an encrypted vault + * today; writing them to a plaintext file beside it would quietly undo that. */ /** diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index e9657914f57..7139ab4f4f2 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -3,6 +3,7 @@ import type { DesktopZoomPercent, TerminalAppearanceTheme } from '@sim/desktop-b import { createLogger } from '@sim/logger' import { isLoopbackHostname } from '@sim/security/ssrf' import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' +import type { DesktopChannel } from '@/shared/channel-identity' /** settings.json is meant to be readable when a user opens it. */ const SETTINGS_INDENT = 2 @@ -42,15 +43,7 @@ export const DEFAULT_ORIGIN = isValidBakedOrigin(process.env.SIM_DESKTOP_DEFAULT ? process.env.SIM_DESKTOP_DEFAULT_ORIGIN : 'https://www.sim.ai' -/** - * The environment a build is keyed to, derived from its baked default origin. - * Channel drives the app's identity — its name and therefore its userData - * directory and single-instance lock — so one developer can keep a prod, - * staging, dev, and localhost install side by side, each with its own - * settings, sessions, and update feed. - */ -export type DesktopChannel = 'prod' | 'staging' | 'dev' | 'local' - +/** The channel a build baked to `origin` belongs to; unrecognized hosts are production. */ export function channelForOrigin(origin: string): DesktopChannel { try { const host = new URL(origin).hostname.toLowerCase() @@ -63,17 +56,6 @@ export function channelForOrigin(origin: string): DesktopChannel { } } -/** - * Per-channel app names. Prod keeps the plain name every existing install - * already has (its userData must not move); the others are distinct apps. - */ -export const APP_NAME_FOR_CHANNEL: Record = { - prod: 'Sim', - staging: 'Sim Staging', - dev: 'Sim Dev', - local: 'Sim Local', -} - export interface WindowBounds { x?: number y?: number diff --git a/apps/desktop/src/main/desktop-chat-session-store.test.ts b/apps/desktop/src/main/desktop-chat-session-store.test.ts index cb49ee7f542..ec704ee45c5 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.test.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.test.ts @@ -115,7 +115,8 @@ describe('DesktopChatSessionStore', () => { expect(onDisk).not.toContain('report.csv') expect(JSON.parse(onDisk)).toEqual({ v: 1, ciphertext: expect.any(String) }) expect(provider.encryptString).toHaveBeenCalledOnce() - expect(statSync(filePath).mode & 0o077).toBe(0) + // POSIX mode bits; on Windows the per-user %APPDATA% ACL keeps the file private. + if (process.platform !== 'win32') expect(statSync(filePath).mode & 0o077).toBe(0) }) it('does not replace the durable store with an oversized encrypted envelope', () => { diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index 9542c34ff5e..f5afad366e6 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -34,6 +34,31 @@ import { DesktopExecutor } from '@/main/desktop-executor/executor' import { createExecutorJournal } from '@/main/desktop-executor/journal' import { createDesktopExecutorService, deviceName } from '@/main/desktop-executor/service' +/** + * A list a test can await until it holds `count` entries. The service reaches Sim and the sleep + * blocker only after real writes to userData, which Windows Defender can hold for hundreds of + * milliseconds each, so waiting on the entry itself beats polling against a fixed deadline. + */ +function awaitableList() { + const items: T[] = [] + const waiters = new Set<{ count: number; resolve: () => void }>() + return { + items, + push(item: T) { + items.push(item) + for (const waiter of waiters) { + if (items.length < waiter.count) continue + waiters.delete(waiter) + waiter.resolve() + } + }, + reached(count: number): Promise { + if (items.length >= count) return Promise.resolve() + return new Promise((resolve) => waiters.add({ count, resolve })) + }, + } +} + /** Sim's device routes, with registration answers held until the test releases them. */ function fakeSim(protocolVersion = 1) { const requests: string[] = [] @@ -43,7 +68,7 @@ function fakeSim(protocolVersion = 1) { * Answers to pending registrations: enabled or not, an HTTP status Sim fails with, or * `'offline'` for a request that never reached Sim. */ - const registrations: Array<(answer: boolean | number | 'offline') => void> = [] + const registrations = awaitableList<(answer: boolean | number | 'offline') => void>() const fetch = vi.fn(async (url: string, init: RequestInit): Promise => { const path = new URL(url).pathname requests.push(`${init.method} ${path}`) @@ -93,13 +118,19 @@ function fakeSim(protocolVersion = 1) { init.signal?.addEventListener('abort', () => reject(new Error('aborted'))) ) }) - return { fetch, requests, registrations, offered } + return { + fetch, + requests, + registrations: registrations.items, + registrationsReached: registrations.reached, + offered, + } } async function service(protocolVersion = 1, userDataPath?: string) { const sim = fakeSim(protocolVersion) /** What the sleep blocker was told, in order. */ - const busy: boolean[] = [] + const busy = awaitableList() const desktopExecutor = createDesktopExecutorService({ userDataPath: userDataPath ?? (await mkdtemp(join(tmpdir(), 'sim-executor-service-'))), origin: () => 'https://sim.test', @@ -118,7 +149,7 @@ async function service(protocolVersion = 1, userDataPath?: string) { }, onBusyChange: (value) => busy.push(value), }) - return { sim, desktopExecutor, busy } + return { sim, desktopExecutor, busy: busy.items, busyReached: busy.reached } } describe('results recovery will hand to the model', () => { @@ -192,7 +223,7 @@ describe('results recovery will hand to the model', () => { try { const { sim, desktopExecutor } = await service(1, userData) desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + await sim.registrationsReached(1) sim.registrations[0]?.(true) await vi.waitFor(() => expect(sim.requests).toContain('POST /api/desktop/tool/complete')) await vi.waitFor(async () => expect(await createExecutorJournal(path).load()).toEqual([])) @@ -210,7 +241,7 @@ describe('results recovery will hand to the model', () => { expect([...(await desktopExecutor.pendingResults())]).toEqual([]) desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + await sim.registrationsReached(1) sim.registrations[0]?.(true) await vi.waitFor(() => expect(sim.requests).toContain('GET /api/desktop/inbox')) @@ -222,7 +253,7 @@ describe('desktop executor registration', () => { it('offers the device for binding once Sim enables it', async () => { const { sim, desktopExecutor } = await service() desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + await sim.registrationsReached(1) sim.registrations[0]?.(true) @@ -233,7 +264,7 @@ describe('desktop executor registration', () => { it('never resumes for a registration that Sim answers after sign-out', async () => { const { sim, desktopExecutor } = await service() desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + await sim.registrationsReached(1) await desktopExecutor.signOut() sim.registrations[0]?.(true) @@ -247,7 +278,7 @@ describe('desktop executor registration', () => { it('offers no binding to a Sim that speaks another protocol version', async () => { const { sim, desktopExecutor } = await service(2) desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + await sim.registrationsReached(1) sim.registrations[0]?.(true) await sleep(100) @@ -259,7 +290,7 @@ describe('desktop executor registration', () => { it('stays dormant while Sim has the executor off: no inbox and no doorbell', async () => { const { sim, desktopExecutor } = await service() desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + await sim.registrationsReached(1) sim.registrations[0]?.(false) await sleep(100) @@ -311,20 +342,26 @@ describe('desktop executor registration', () => { } }) - it('registers with no install id it could not save', async () => { - const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) - await chmod(userData, 0o500) - try { - const { sim, desktopExecutor } = await service(1, userData) - - desktopExecutor.start() - await sleep(200) - - expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual([]) - } finally { - await chmod(userData, 0o700) + // chmod cannot make a Windows directory unwritable. + it.skipIf(process.platform === 'win32')( + 'registers with no install id it could not save', + async () => { + const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) + await chmod(userData, 0o500) + try { + const { sim, desktopExecutor } = await service(1, userData) + + desktopExecutor.start() + await sleep(200) + + expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual( + [] + ) + } finally { + await chmod(userData, 0o700) + } } - }) + ) it('keeps its install id through a read failure instead of minting a new one', async () => { const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) @@ -339,12 +376,13 @@ describe('desktop executor registration', () => { }) it('releases the sleep blocker at sign-out while a call is still running', async () => { - const { sim, desktopExecutor, busy } = await service() + const { sim, desktopExecutor, busy, busyReached } = await service() sim.offered.push('call-1') desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + await sim.registrationsReached(1) sim.registrations[0]?.(true) - await vi.waitFor(() => expect(busy).toEqual([true])) + await busyReached(1) + expect(busy).toEqual([true]) await desktopExecutor.signOut() diff --git a/apps/desktop/src/main/dialogs.ts b/apps/desktop/src/main/dialogs.ts index 1f2a5e60236..8188786d536 100644 --- a/apps/desktop/src/main/dialogs.ts +++ b/apps/desktop/src/main/dialogs.ts @@ -74,6 +74,9 @@ export function showShellDialog( app.isPackaged ), }) + // Windows attaches the app menu to every window; its accelerators would + // otherwise act on (and close) this sheet behind the page's back. + if (process.platform === 'win32') win.removeMenu() const pageUrl = localPageUrl('dialog.html') attachShellTheme(win) let settled = false diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts index 22302421def..c85b1c7236c 100644 --- a/apps/desktop/src/main/downloads.test.ts +++ b/apps/desktop/src/main/downloads.test.ts @@ -42,6 +42,13 @@ describe('sanitizeFilename', () => { expect(sanitized).not.toContain('/') expect(sanitized.startsWith('.')).toBe(false) }) + + it('keeps a long reserved Windows device name within the length cap once prefixed', () => { + const sanitized = sanitizeFilename(`con.${'a'.repeat(300)}`, 'win32') + + expect(sanitized.startsWith('_con.')).toBe(true) + expect(sanitized.length).toBeLessThanOrEqual(200) + }) }) describe('uniqueDownloadPath', () => { @@ -74,7 +81,7 @@ describe('uniqueDownloadPath', () => { ]) expect(new Set([first, second])).toEqual( - new Set(['/Downloads/report.csv', '/Downloads/report (copy-1).csv']) + new Set([join('/Downloads', 'report.csv'), join('/Downloads', 'report (copy-1).csv')]) ) }) }) diff --git a/apps/desktop/src/main/downloads.ts b/apps/desktop/src/main/downloads.ts index 718b3f7429c..f052d683aab 100644 --- a/apps/desktop/src/main/downloads.ts +++ b/apps/desktop/src/main/downloads.ts @@ -3,9 +3,10 @@ import { basename, extname, join } from 'node:path' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import type { DownloadItem, Session } from 'electron' -import { app } from 'electron' +import { app, BrowserWindow } from 'electron' import { isAgentWebContents } from '@/main/browser-agent/registry' import type { EventRecorder } from '@/main/observability' +import { toWin32SafeFileName } from '@/main/win32-path' const logger = createLogger('DesktopDownloads') @@ -27,15 +28,24 @@ const MIME_EXTENSIONS: Record = { /** * Strips path separators and control characters from a server- or - * blob-suggested filename so it can never escape the chosen directory. + * blob-suggested filename so it can never escape the chosen directory. On + * Windows it also becomes a name NTFS stores exactly as spelled. */ -export function sanitizeFilename(name: string): string { +export function sanitizeFilename( + name: string, + platform: NodeJS.Platform = process.platform +): string { const cleaned = name .replace(/[/\\]/g, '_') .replace(/[\u0000-\u001f]/g, '') .replace(/^\.+/, '') .trim() - return cleaned.slice(0, MAX_FILENAME_LENGTH) + .slice(0, MAX_FILENAME_LENGTH) + if (platform !== 'win32') return cleaned + const safe = toWin32SafeFileName(cleaned) + return safe.length > MAX_FILENAME_LENGTH + ? toWin32SafeFileName(cleaned.slice(0, MAX_FILENAME_LENGTH - 1)) + : safe } /** @@ -142,10 +152,27 @@ export function countProgressingAppDownloads(): number { return count } +/** Windows flashing until focused, so several downloads finishing in the background share one listener. */ +const flashingWindows = new WeakSet() + +/** The Windows counterpart of the Dock bounce: a taskbar flash for a backgrounded window. */ +function flashUntilFocused(win: BrowserWindow | null): void { + if (!win || win.isDestroyed() || win.isFocused()) return + if (!flashingWindows.has(win)) { + flashingWindows.add(win) + win.once('focus', () => { + flashingWindows.delete(win) + if (!win.isDestroyed()) win.flashFrame(false) + }) + } + win.flashFrame(true) +} + /** * Wires will-download so exports, blob URLs, and presigned-URL downloads all * get a native save dialog with a sensible default name, and completed - * downloads bounce the Dock Downloads stack. + * downloads bounce the Dock Downloads stack (or flash the taskbar button on + * Windows). */ export function attachDownloadHandling(session: Session, events: EventRecorder): void { session.on('will-download', (_event, item, contents) => { @@ -161,6 +188,8 @@ export function attachDownloadHandling(session: Session, events: EventRecorder): logger.info('Download completed', { filename }) if (process.platform === 'darwin') { app.dock?.downloadFinished(item.getSavePath()) + } else if (process.platform === 'win32' && contents) { + flashUntilFocused(BrowserWindow.fromWebContents(contents)) } } else if (state === 'interrupted') { logger.warn('Download interrupted', { filename }) diff --git a/apps/desktop/src/main/file-identity.ts b/apps/desktop/src/main/file-identity.ts new file mode 100644 index 00000000000..e7f2ccec720 --- /dev/null +++ b/apps/desktop/src/main/file-identity.ts @@ -0,0 +1,29 @@ +import type { LocalFilesystemEntryKind } from '@sim/desktop-bridge' + +/** + * A file's identity as `stat(path, { bigint: true })` reports it. Bigint because NTFS file IDs + * exceed 2^53, and rounded IDs could compare equal. + */ +export interface FileIdentity { + dev: bigint + ino: bigint +} + +/** What `fs.Stats` and `fs.Dirent` both say about a file's type. */ +interface FileTypeInfo { + isFile(): boolean + isDirectory(): boolean + isSymbolicLink(): boolean +} + +export function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +/** The entry kind a listing reports for a stat or directory entry. */ +export function fileEntryKind(info: FileTypeInfo): LocalFilesystemEntryKind { + if (info.isFile()) return 'file' + if (info.isDirectory()) return 'directory' + if (info.isSymbolicLink()) return 'symlink' + return 'other' +} diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts index 6307ebb063d..f1d42251633 100644 --- a/apps/desktop/src/main/handoff.test.ts +++ b/apps/desktop/src/main/handoff.test.ts @@ -1,6 +1,6 @@ import { get as httpGet } from 'node:http' -import type { BrowserWindow } from 'electron' -import { describe, expect, it, vi } from 'vitest' +import { BrowserWindow } from 'electron' +import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -164,28 +164,31 @@ describe('createHandoffManager', () => { }) describe('login handoff landing', () => { - it('lands on the app entry after redeeming the login token', async () => { - const win = { - isDestroyed: () => false, - loadURL: vi.fn(async () => {}), - show: vi.fn(), - focus: vi.fn(), - webContents: { - getURL: () => 'https://sim.ai/login', - executeJavaScript: vi.fn(async () => 200), - }, - } - const flow = createAuthFlow({ - handoff: { consume: () => true } as unknown as ReturnType, - origin: () => 'https://sim.ai', - events: makeEvents(), - ensureMainWindow: async () => win as unknown as BrowserWindow, - }) + const realPlatform = process.platform - await flow.handleCallback({ token: VALID_TOKEN, state: _VALID_STATE }) - - expect(win.loadURL).toHaveBeenCalledExactlyOnceWith('https://sim.ai/home') + afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) }) + + it.each(['darwin', 'win32'] as const)( + 'lands on the app entry after redeeming the login token on %s', + async (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + const win = new BrowserWindow() + vi.mocked(win.webContents.getURL).mockReturnValue('https://sim.ai/login') + vi.mocked(win.webContents.executeJavaScript).mockResolvedValue(200) + const flow = createAuthFlow({ + handoff: { consume: () => true } as unknown as ReturnType, + origin: () => 'https://sim.ai', + events: makeEvents(), + ensureMainWindow: async () => win, + }) + + await flow.handleCallback({ token: VALID_TOKEN, state: _VALID_STATE }) + + expect(win.loadURL).toHaveBeenCalledExactlyOnceWith('https://sim.ai/home') + } + ) }) describe('connect handoff account pinning', () => { diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index 40fd7e3586c..e9bd1a5634f 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -5,10 +5,10 @@ import { safeCompare } from '@sim/security/compare' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import type { BrowserWindow } from 'electron' -import { app } from 'electron' import { APP_ENTRY_ROUTE } from '@/main/app-routes' import { showShellDialog } from '@/main/dialogs' import type { EventRecorder } from '@/main/observability' +import { bringWindowToFront } from '@/main/shell-window' const logger = createLogger('DesktopHandoff') @@ -524,9 +524,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { try { await win.loadURL(`${origin}${APP_ENTRY_ROUTE}`) } catch {} - win.show() - win.focus() - app.focus({ steal: true }) + bringWindowToFront(win) }, } } diff --git a/apps/desktop/src/main/help-search.ts b/apps/desktop/src/main/help-search.ts index 8b9eca5810d..a624d3498aa 100644 --- a/apps/desktop/src/main/help-search.ts +++ b/apps/desktop/src/main/help-search.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { DOCS_SEARCH_ENDPOINT } from '@/main/external-links' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopHelpSearch') @@ -12,7 +13,7 @@ interface NativeHelpSearchBridge { interface HelpSearchRuntime { isElectron: boolean - isMacOS: boolean + isSupported: boolean loadBridge: () => unknown } @@ -30,7 +31,7 @@ function loadNativeBridge(): unknown { const nativeRuntime: HelpSearchRuntime = { isElectron: Boolean(process.versions.electron), - isMacOS: process.platform === 'darwin', + isSupported: desktopFeatures(process.platform).helpSearch, loadBridge: loadNativeBridge, } @@ -39,7 +40,7 @@ const nativeRuntime: HelpSearchRuntime = { * The bridge is optional so non-macOS development and unit tests remain portable. */ export function installDocumentationHelpSearch(runtime = nativeRuntime): boolean { - if (!runtime.isMacOS || !runtime.isElectron) return false + if (!runtime.isSupported || !runtime.isElectron) return false try { const bridge = runtime.loadBridge() diff --git a/apps/desktop/src/main/in-flight-work.test.ts b/apps/desktop/src/main/in-flight-work.test.ts index 4a218ab7268..3cf89304aec 100644 --- a/apps/desktop/src/main/in-flight-work.test.ts +++ b/apps/desktop/src/main/in-flight-work.test.ts @@ -30,6 +30,7 @@ describe('countWindowsWithUnsavedChanges', () => { }) describe('describeInFlightWork', () => { + const zsh = (command: string) => ({ command, shell: 'zsh' }) const IDLE: InFlightWork = { terminalCommands: [], uncheckedTerminalCount: 0, @@ -51,23 +52,35 @@ describe('describeInFlightWork', () => { }) it('starts the sentence for an unannounced command with a capital', () => { - expect(describeInFlightWork({ ...IDLE, terminalCommands: [''] })).toEqual([ + expect(describeInFlightWork({ ...IDLE, terminalCommands: [zsh('')] })).toEqual([ 'A command is still running in a terminal.', ]) - expect(describeInFlightWork({ ...IDLE, terminalCommands: ['npm run dev'] })).toEqual([ + expect(describeInFlightWork({ ...IDLE, terminalCommands: [zsh('npm run dev')] })).toEqual([ 'npm is still running in a terminal.', ]) }) it('counts unannounced commands rather than naming "a command" twice', () => { - expect(describeInFlightWork({ ...IDLE, terminalCommands: ['', ''] })).toEqual([ + expect(describeInFlightWork({ ...IDLE, terminalCommands: [zsh(''), zsh('')] })).toEqual([ '2 terminal commands are still running.', ]) - expect(describeInFlightWork({ ...IDLE, terminalCommands: ['', 'npm run dev'] })).toEqual([ - 'npm and a command are still running in terminals.', - ]) expect( - describeInFlightWork({ ...IDLE, terminalCommands: ['', 'npm run dev', 'claude'] }) + describeInFlightWork({ ...IDLE, terminalCommands: [zsh(''), zsh('npm run dev')] }) + ).toEqual(['npm and a command are still running in terminals.']) + expect( + describeInFlightWork({ + ...IDLE, + terminalCommands: [zsh(''), zsh('npm run dev'), zsh('claude')], + }) ).toEqual(['3 terminal commands are still running.']) }) + + it("names a Windows command by its program, read in its own shell's syntax", () => { + expect( + describeInFlightWork({ + ...IDLE, + terminalCommands: [{ command: 'C:\\tools\\node.exe server.js', shell: 'pwsh' }], + }) + ).toEqual(['node is still running in a terminal.']) + }) }) diff --git a/apps/desktop/src/main/in-flight-work.ts b/apps/desktop/src/main/in-flight-work.ts index 3203b675a85..e2d59955d31 100644 --- a/apps/desktop/src/main/in-flight-work.ts +++ b/apps/desktop/src/main/in-flight-work.ts @@ -3,6 +3,7 @@ import { describeRunningCommand } from '@sim/terminal-protocol' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import type { WebContents } from 'electron' +import type { RunningTerminalCommand } from '@/main/terminal' const logger = createLogger('DesktopInFlightWork') @@ -60,8 +61,8 @@ export async function countWindowsWithUnsavedChanges( } export interface InFlightWork { - /** The command line holding each busy terminal's foreground (`''` when unannounced). */ - terminalCommands: readonly string[] + /** The command holding each busy terminal's foreground. */ + terminalCommands: readonly RunningTerminalCommand[] /** Terminals whose foreground could not be read, which may be running a command. */ uncheckedTerminalCount: number /** The app a computer-use action is driving, `''` when it has no name, or null when idle. */ @@ -80,9 +81,11 @@ export interface InFlightWork { * One sentence for the busy terminals. Commands are named while at most two are running and * at most one is unannounced; past that, two "a command"s would say nothing, so they are counted. */ -function describeTerminalCommands(commands: readonly string[]): string | null { +function describeTerminalCommands(commands: readonly RunningTerminalCommand[]): string | null { if (commands.length === 0) return null - const named = commands.filter((command) => command.trim()).map(describeRunningCommand) + const named = commands + .filter(({ command }) => command.trim()) + .map(({ command, shell }) => describeRunningCommand(command, shell)) const unnamed = commands.length - named.length if (unnamed > 1 || commands.length > MAX_NAMED_TERMINAL_COMMANDS) { return `${commands.length} terminal commands are still running.` diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a0efc04faf5..6a1c3063e76 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -60,7 +60,6 @@ import { attachClientInfo } from '@/main/client-info' import { NativeComputerUseClient } from '@/main/computer-use/native-client' import { ComputerUseService } from '@/main/computer-use/service' import { - APP_NAME_FOR_CHANNEL, channelForOrigin, createConfigStore, DEFAULT_ORIGIN, @@ -110,14 +109,17 @@ import { resolveStartRoute, } from '@/main/session-lifecycle' import { setShellTheme } from '@/main/shell-theme' +import { bringWindowToFront } from '@/main/shell-window' import { attachTelemetryPolicy } from '@/main/telemetry-policy' import { TerminalRegistry } from '@/main/terminal/registry' import { createRunLedger } from '@/main/terminal/run-ledger' import { installTray, type TrayHandle } from '@/main/tray' import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater' import { installBrowserUserAgent } from '@/main/user-agent' -import { createMainWindow, setupPermissionHandlers } from '@/main/window' +import { createMainWindow, handleSecondInstance, setupPermissionHandlers } from '@/main/window' import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' +import { CHANNEL_IDENTITIES } from '@/shared/channel-identity' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopMain') @@ -135,13 +137,6 @@ function reportHandoffFailure(error: unknown): void { /** Bounds what a hand-edited settings file can make an update relaunch open. */ const MAX_RELAUNCH_WINDOWS = 16 -const DOCK_ICON_FOR_CHANNEL = { - prod: 'dock-icon.png', - staging: 'dock-icon-staging.png', - dev: 'dock-icon-dev.png', - local: 'dock-icon-local.png', -} as const - function main(): void { app.enableSandbox() @@ -195,7 +190,8 @@ function main(): void { const computerStopShortcut = 'CommandOrControl+Shift+Escape' const computerUse = new ComputerUseService({ config, - supported: process.platform === 'darwin' && Number.parseInt(release(), 10) >= 23, + supported: + desktopFeatures(process.platform).computerUse && Number.parseInt(release(), 10) >= 23, native: computerNative, openPermissionSettings: (permission) => shell.openExternal( @@ -265,6 +261,16 @@ function main(): void { createRunLedger(join(userDataPath, 'terminal-runs')) ) const preloadPath = join(__dirname, 'preload.cjs') + // Packaged Windows builds inherit the executable's icon; a dev run would show Electron's. + const unpackagedWindowIcon = + process.platform === 'win32' && !app.isPackaged + ? join( + __dirname, + '..', + 'static', + CHANNEL_IDENTITIES[channelForOrigin(processOrigin)].dockIcon + ) + : undefined const windows = new Set() const loadHealthByWindow = new Map() @@ -310,14 +316,11 @@ function main(): void { /** Restore/show/focus one full Sim window and activate the app. */ function showMainWindow(target?: BrowserWindow | null): void { const win = target ?? getMainWindow() - if (win) { - if (win.isMinimized()) { - win.restore() - } - win.show() - win.focus() + if (!win) { + app.focus({ steal: true }) + return } - app.focus({ steal: true }) + bringWindowToFront(win) } const handoff = createHandoffManager( @@ -514,6 +517,7 @@ function main(): void { const win = createMainWindow({ config, events, + icon: unpackagedWindowIcon, appOrigin, partition: appPartition(origin), preloadPath, @@ -847,13 +851,21 @@ function main(): void { } app.on('second-instance', () => { - void (startupReady ?? app.whenReady()).then(() => createAndLoadAppWindow()) + void (startupReady ?? app.whenReady()).then(() => + handleSecondInstance({ + ensureMainWindow, + createWindow: () => createAndLoadAppWindow(), + showWindow: showMainWindow, + }) + ) }) app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } + if (process.platform === 'darwin') return + // The Windows notification-area icon keeps Sim resident, like the macOS + // Dock: closing the last window hides to the tray; Exit and Quit Sim quit. + if (process.platform === 'win32' && tray) return + app.quit() }) app.on('before-quit', (event) => { @@ -916,7 +928,7 @@ function main(): void { // bundle, so they still need the channel-specific development icon. if (process.platform === 'darwin' && !app.isPackaged) { const channel = channelForOrigin(config.getOrigin()) - app.dock?.setIcon(join(__dirname, '..', 'static', DOCK_ICON_FOR_CHANNEL[channel])) + app.dock?.setIcon(join(__dirname, '..', 'static', CHANNEL_IDENTITIES[channel].dockIcon)) } events.record('app_launch', { version: app.getVersion(), @@ -1215,7 +1227,10 @@ function main(): void { // The name follows the build's channel ("Sim", "Sim Dev", …) so one developer // can run one install per environment side by side — separate settings, // sessions, locks, and update feeds. -app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) +app.setName(CHANNEL_IDENTITIES[channelForOrigin(DEFAULT_ORIGIN)].name) +if (process.platform === 'win32') { + app.setAppUserModelId(CHANNEL_IDENTITIES[channelForOrigin(DEFAULT_ORIGIN)].appId) +} if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) } diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 030c9620362..3c83c00ac16 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -319,6 +319,7 @@ describe('registerIpcHandlers', () => { deps = { appOrigin: () => APP, getExecutorDevice: () => null, + platform: 'darwin', allowHttpLocalhost: () => false, accountDataAvailable: () => true, isLocalPageUrl, @@ -382,6 +383,35 @@ describe('registerIpcHandlers', () => { for (const root of computerRoots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + it.each([ + 'terminal-themes:list-profiles', + 'computer-use:execute-tool', + 'browser-import:all', + 'browser-import:continue-sign-in', + 'browser-import:bring-sign-in-back', + 'browser-import:cancel-sign-in', + 'browser-import:pending-sign-in', + 'browser-credentials:import', + 'browser-credentials:copy', + ])('registers no handler on Windows for %s, which that shell does not ship', (channel) => { + expect(collectHandlers().invoke.has(channel) || collectHandlers().on.has(channel)).toBe(true) + vi.mocked(ipcMain.handle).mockClear() + vi.mocked(ipcMain.on).mockClear() + + registerIpcHandlers({ ...deps, platform: 'win32' }) + + const { invoke, on } = collectHandlers() + expect(invoke.has(channel) || on.has(channel)).toBe(false) + }) + + it('keeps password reveal on Windows while copy and import are withheld', () => { + vi.mocked(ipcMain.handle).mockClear() + vi.mocked(ipcMain.on).mockClear() + registerIpcHandlers({ ...deps, platform: 'win32' }) + + expect(collectHandlers().invoke.has('browser-credentials:reveal')).toBe(true) + }) + it('opens validated external URLs only after recent user input', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:open-external') @@ -403,8 +433,9 @@ describe('registerIpcHandlers', () => { expect(await handler?.(evilEvent)).toBe(false) expect(await handler?.(appEvent)).toBe(false) - expect(await handler?.(activeAppEvent)).toBe(process.platform === 'darwin') - expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0) + const hasPrivacySettings = process.platform === 'darwin' || process.platform === 'win32' + expect(await handler?.(activeAppEvent)).toBe(hasPrivacySettings) + expect(shell.openExternal).toHaveBeenCalledTimes(hasPrivacySettings ? 1 : 0) }) it('restricts the OAuth connect handoff to an activated app origin', async () => { diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 068b315b7e7..fe296114d61 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -104,6 +104,7 @@ import { isAppOrigin, openExternalSafe } from '@/main/navigation' import type { ScopedEventRouter } from '@/main/scoped-event-router' import type { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' +import { type DesktopFeatures, desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopIpc') @@ -400,6 +401,8 @@ export interface IpcDeps { } /** The device a new chat turn may bind to, or null while Sim has not enabled one. */ getExecutorDevice: () => DesktopExecutorDevice | null + /** Selects the {@link desktopFeatures} whose channels are registered. Defaults to the host. */ + platform?: NodeJS.Platform } /** @@ -427,6 +430,12 @@ type ChannelFeature = 'browser' | 'terminal' interface ChannelSpecBase { gate: ChannelGate + /** + * The platform feature the channel belongs to; absent when every platform has it. The preload + * omits these surfaces where the platform lacks them, and main never registers the channel + * there, so a renderer that calls one anyway finds no handler. + */ + platformFeature?: keyof DesktopFeatures passSender?: boolean requires?: ChannelFeature /** Account-bearing storage must be readable and writable before this channel can run. */ @@ -716,6 +725,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:get-activity': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: null, handler: () => deps.computerUse?.getActivity() ?? null, @@ -723,6 +733,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:status': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: null, handler: () => deps.computerUse?.getStatus(), @@ -730,6 +741,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:set-enabled': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: null, @@ -742,6 +754,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:request-permission': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: null, @@ -754,6 +767,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:set-ask-for-apps': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: null, @@ -766,6 +780,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:list-permissions': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: [], handler: () => deps.computerUse?.listAppPermissions() ?? [], @@ -773,6 +788,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:revoke-app': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: undefined, @@ -785,6 +801,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:cancel': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', denied: undefined, handler: (toolCallId) => { if (toolCallId !== undefined && !isDesktopToolCallId(toolCallId)) @@ -795,6 +812,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:execute-tool': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: undefined, handler: (scopeId, toolCallId, params) => { @@ -984,6 +1002,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'terminal-themes:list-profiles': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'terminalThemes', requires: 'terminal', denied: [], handler: () => listTerminalThemeProfiles(), @@ -991,6 +1010,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'terminal-themes:select-profile': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'terminalThemes', requires: 'terminal', needsUserActivation: true, denied: null, @@ -1483,6 +1503,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:list-profiles': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', denied: [], handler: () => listChromeImportProfiles(), @@ -1490,6 +1511,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:cookies': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', needsUserActivation: true, denied: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, @@ -1506,6 +1528,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:all': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', needsUserActivation: true, denied: { @@ -1541,6 +1564,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:continue-sign-in': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', passSender: true, needsUserActivation: true, @@ -1559,6 +1583,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:bring-sign-in-back': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', passSender: true, needsUserActivation: true, @@ -1588,6 +1613,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:cancel-sign-in': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', passSender: true, denied: undefined, @@ -1599,6 +1625,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:pending-sign-in': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', passSender: true, denied: null, @@ -1742,6 +1769,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:sites': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', 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', @@ -1764,6 +1792,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:copy': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'passwordCopy', deviationReason: 'OS-authenticated password copying does not require an active browser surface', requiresAccountData: true, @@ -1794,6 +1823,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:import': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', deviationReason: "unlike its siblings this WRITES new credentials by driving the embedded browser's import path, so it needs the surface the rest of the family deliberately does without", requires: 'browser', @@ -2231,7 +2261,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { args: readonly unknown[] ): boolean => (typeof requirement === 'function' ? requirement(args) : requirement === true) + const features = desktopFeatures(deps.platform ?? process.platform) + for (const [channel, spec] of Object.entries(channels)) { + if (spec.platformFeature && !features[spec.platformFeature]) continue if (spec.kind === 'invoke') { ipcMain.handle(channel, async (event, ...args) => { if ( diff --git a/apps/desktop/src/main/local-file-permissions.test.ts b/apps/desktop/src/main/local-file-permissions.test.ts new file mode 100644 index 00000000000..4403e1190bf --- /dev/null +++ b/apps/desktop/src/main/local-file-permissions.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { nativePath } from '@/main/local-file-permissions' + +const HOME = 'C:\\Users\\me' +const windowsPath = (value: string) => nativePath(value, 'win32', () => HOME) + +describe('native paths on Windows', () => { + it.each([ + ['a UNC share, which would authenticate to the host', '\\\\attacker\\share\\file.txt'], + ['a UNC share with forward slashes', '//attacker/share/file.txt'], + ['a \\\\?\\ verbatim path', '\\\\?\\C:\\Users\\me\\file.txt'], + ['a \\\\.\\ device path', '\\\\.\\pipe\\secret'], + ['a rooted path without a drive', '\\Users\\me\\file.txt'], + ['a POSIX-style rooted path', '/Users/me/file.txt'], + ['a drive-relative path', 'C:Users\\me'], + ['a UNC path after the home prefix', '~\\\\attacker\\share'], + ['a reserved device name', 'C:\\Users\\me\\CON'], + ['a reserved device name with an extension', 'C:\\Users\\me\\nul.txt'], + ['a numbered reserved device', 'C:\\Users\\me\\com1'], + ['an alternate data stream', 'C:\\Users\\me\\file.txt:hidden'], + ['a segment with a trailing dot', 'C:\\Users\\me\\file.'], + ['a segment with a trailing space', 'C:\\Users\\me\\folder \\file.txt'], + ['a wildcard', 'C:\\Users\\me\\*.txt'], + ['a NUL byte', 'C:\\Users\\me\\a\0b'], + ])('refuses %s', (_label, value) => { + expect(() => windowsPath(value)).toThrow() + }) + + it('resolves a drive-absolute path', () => { + expect(windowsPath('C:\\Users\\me\\notes\\..\\todo.txt')).toBe('C:\\Users\\me\\todo.txt') + expect(windowsPath('d:/Projects/sim')).toBe('d:\\Projects\\sim') + }) + + it('resolves ~ paths under the home directory', () => { + expect(windowsPath('~')).toBe(HOME) + expect(windowsPath('~\\Documents\\a.txt')).toBe('C:\\Users\\me\\Documents\\a.txt') + expect(windowsPath('~/Documents/a.txt')).toBe('C:\\Users\\me\\Documents\\a.txt') + }) + + it('cannot be walked off the drive with ..', () => { + expect(windowsPath('C:\\..\\..\\Windows')).toBe('C:\\Windows') + }) +}) diff --git a/apps/desktop/src/main/local-file-permissions.ts b/apps/desktop/src/main/local-file-permissions.ts index 9504f591327..ddfd6ee0cf7 100644 --- a/apps/desktop/src/main/local-file-permissions.ts +++ b/apps/desktop/src/main/local-file-permissions.ts @@ -1,4 +1,4 @@ -import { lstat, realpath, stat } from 'node:fs/promises' +import { lstat, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { isDesktopScopeId } from '@sim/desktop-bridge' @@ -6,7 +6,9 @@ import type { BrowserWindow } from 'electron' import { showShellDialog } from '@/main/dialogs' import type { LocalFileAuthorization } from '@/main/local-files' import type { LocalFileAccess, LocalFilesystemService } from '@/main/local-filesystem' +import { localRealpath } from '@/main/local-realpath' import { openNativeFile } from '@/main/native-directory' +import { resolveWin32NativePath } from '@/main/win32-path' const MAX_PENDING_REQUESTS = 32 @@ -25,11 +27,21 @@ interface PendingFolderDecision { decision: Promise } -function nativePath(value: unknown): string { +/** + * Lexically resolves a model-supplied path without touching the filesystem. On + * Windows it refuses a path that is itself a share; a link inside the path is + * left to {@link localRealpath}, which callers resolve it with. + */ +export function nativePath( + value: unknown, + platform: NodeJS.Platform = process.platform, + home: () => string = homedir +): string { if (typeof value !== 'string' || value.length > 4096 || value.includes('\0')) throw new Error('A native absolute path or ~/ path is required.') + if (platform === 'win32') return resolveWin32NativePath(value, home()) const path = - value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value + value === '~' ? home() : value.startsWith('~/') ? join(home(), value.slice(2)) : value if (!isAbsolute(path)) throw new Error('Use an absolute path or ~/ path.') return resolve(path) } @@ -70,7 +82,7 @@ export class LocalFilePermissions { !isDesktopScopeId(authorization.args.folderId))) ) throw new Error('A valid destination workspace and folder are required for imports.') - const path = await realpath(nativePath(authorization.args.path)) + const path = await localRealpath(nativePath(authorization.args.path)) if (this.fullFileAccess()) { return this.unrestrictedAccess(path, context) } @@ -120,7 +132,7 @@ export class LocalFilePermissions { return this.authorizedAccess( { path, - resolve: realpath, + resolve: (requested) => localRealpath(requested), open: (requested, directory = false) => openNativeFile(folder, relative(folder, requested), identity, directory), }, diff --git a/apps/desktop/src/main/local-files.test.ts b/apps/desktop/src/main/local-files.test.ts index fd7fe391d4d..d569e175062 100644 --- a/apps/desktop/src/main/local-files.test.ts +++ b/apps/desktop/src/main/local-files.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { app } from 'electron' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -11,155 +11,194 @@ import { executeLocalFileRequest as executeApprovedLocalFileRequest, type LocalFileAuthorization, } from '@/main/local-files' -import { openNativeFile } from '@/main/native-directory' +import { type openNativeFile, openPosixNativeFile } from '@/main/native-directory' +import { openWin32NativeFile } from '@/main/win32-native-files' -/** Parser and import invariants run with explicit fixture access; consent is covered through Electron. */ -function executeLocalFileRequest(request: unknown, authorization: LocalFileAuthorization) { - return executeApprovedLocalFileRequest(request, authorization, { - path: String(authorization.args.path), - resolve: realpath, - open: async (path, directory = false) => - openNativeFile(root, relative(root, path), await stat(root, { bigint: true }), directory), - }) -} +const isWindows = process.platform === 'win32' -let root: string -beforeEach(async () => { - vi.mocked(app.getAppPath).mockReturnValue(fileURLToPath(new URL('../..', import.meta.url))) - root = await realpath(await mkdtemp(join(tmpdir(), 'sim-native-files-'))) -}) -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) +/** Both backends run where they can; the POSIX native module is not built on Windows. */ +const BACKENDS: Array<[string, typeof openNativeFile]> = [['Windows', openWin32NativeFile]] +if (!isWindows) BACKENDS.push(['POSIX', openPosixNativeFile]) -it('rejects missing paths and directory cycles with explicit errors before uploading', async () => { - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { toolName: 'read_local_file', args: { path: join(root, 'missing') } } - ) - ).toMatchObject({ ok: false }) - await symlink(root, join(root, 'cycle')) - expect( - await executeLocalFileRequest( - { operation: 'manifest' }, - { toolName: 'import_local_files', args: { path: root, targetWorkspaceId: 'target' } } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('cycle') }) -}) +/** A junction on Windows, which needs no privilege; a symlink elsewhere. */ +function linkDirectory(target: string, path: string) { + return symlink(target, path, isWindows ? 'junction' : 'dir') +} -it('refuses oversized import files before any workspace mutation or bulk allocation', async () => { - const path = join(root, 'large.bin') - await writeFile(path, '') - await truncate(path, 64 * 1024 * 1024 + 1) - expect( - await executeLocalFileRequest( - { operation: 'manifest' }, - { toolName: 'import_local_files', args: { path, targetWorkspaceId: 'workspace' } } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('64 MB') }) -}) +/** File symlinks need Developer Mode or elevation on Windows. */ +async function canLinkFiles(): Promise { + const probe = await mkdtemp(join(tmpdir(), 'sim-symlink-probe-')) + try { + await writeFile(join(probe, 'target'), '') + await symlink(join(probe, 'target'), join(probe, 'link'), 'file') + return true + } catch { + return false + } finally { + await rm(probe, { recursive: true, force: true }) + } +} -it.each(['file', 'directory'] as const)( - 'rejects %s symlinks outside the import source during manifest and chunk reads', - async (kind) => { - const source = join(root, 'selected') - const outside = join(root, 'selected-other') - await mkdir(source) - await mkdir(outside) - await writeFile(join(outside, 'private.txt'), 'outside content') - const target = kind === 'file' ? join(outside, 'private.txt') : outside - await symlink(target, join(source, 'link')) - const authorization = { - toolName: 'import_local_files', - args: { path: source, targetWorkspaceId: 'target' }, - } - expect(await executeLocalFileRequest({ operation: 'manifest' }, authorization)).toMatchObject({ - ok: false, - error: expect.stringContaining('outside this import source'), +const fileLinks = await canLinkFiles() + +describe.each(BACKENDS)('local file requests on the %s backend', (_name, openBeneath) => { + /** Parser and import invariants run with explicit fixture access; consent is covered through Electron. */ + function executeLocalFileRequest(request: unknown, authorization: LocalFileAuthorization) { + return executeApprovedLocalFileRequest(request, authorization, { + path: String(authorization.args.path), + resolve: realpath, + open: async (path, directory = false) => + openBeneath(root, relative(root, path), await stat(root, { bigint: true }), directory), }) - const outsideManifest = await executeLocalFileRequest( - { operation: 'manifest' }, - { toolName: 'import_local_files', args: { path: target, targetWorkspaceId: 'target' } } - ) - if (!outsideManifest.ok || outsideManifest.data.kind !== 'manifest') - throw new Error('Expected manifest') - const entry = outsideManifest.data.entries.find((item) => item.kind === 'file')! + } + + let root: string + beforeEach(async () => { + vi.mocked(app.getAppPath).mockReturnValue(fileURLToPath(new URL('../..', import.meta.url))) + root = await realpath(await mkdtemp(join(tmpdir(), 'sim-native-files-'))) + }) + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('rejects missing paths and directory cycles with explicit errors before uploading', async () => { expect( await executeLocalFileRequest( - { - operation: 'chunk', - relativePath: kind === 'file' ? 'link' : 'link/private.txt', - revision: entry.revision, - offset: 0, - }, - authorization + { operation: 'read' }, + { toolName: 'read_local_file', args: { path: join(root, 'missing') } } ) - ).toMatchObject({ ok: false, error: expect.stringContaining('outside this import source') }) - } -) - -it('supports internal symlinks and an explicitly selected symlink root, but rejects a retargeted child', async () => { - const source = join(root, 'selected') - await mkdir(source) - await writeFile(join(source, 'notes.txt'), 'inside') - await symlink(join(source, 'notes.txt'), join(source, 'alias.txt')) - const selectedAlias = join(root, 'selected-alias') - await symlink(source, selectedAlias) - const authorization = { - toolName: 'import_local_files', - args: { path: selectedAlias, targetWorkspaceId: 'target' }, - } - const manifest = await executeLocalFileRequest({ operation: 'manifest' }, authorization) - if (!manifest.ok || manifest.data.kind !== 'manifest') throw new Error('Expected manifest') - const entry = manifest.data.entries.find((item) => item.relativePath === 'alias.txt')! - const request = { - operation: 'chunk', - relativePath: 'alias.txt', - revision: entry.revision, - offset: 0, - } - expect(await executeLocalFileRequest(request, authorization)).toEqual({ - ok: true, - data: { kind: 'chunk', bytes: new Uint8Array(Buffer.from('inside')), eof: true }, + ).toMatchObject({ ok: false }) + await linkDirectory(root, join(root, 'cycle')) + expect( + await executeLocalFileRequest( + { operation: 'manifest' }, + { toolName: 'import_local_files', args: { path: root, targetWorkspaceId: 'target' } } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('cycle') }) }) - await writeFile(join(root, 'outside.txt'), 'outside') - await rm(join(source, 'alias.txt')) - await symlink(join(root, 'outside.txt'), join(source, 'alias.txt')) - expect(await executeLocalFileRequest(request, authorization)).toMatchObject({ - ok: false, - error: expect.stringContaining('outside this import source'), + + it('refuses oversized import files before any workspace mutation or bulk allocation', async () => { + const path = join(root, 'large.bin') + await writeFile(path, '') + await truncate(path, 64 * 1024 * 1024 + 1) + expect( + await executeLocalFileRequest( + { operation: 'manifest' }, + { toolName: 'import_local_files', args: { path, targetWorkspaceId: 'workspace' } } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('64 MB') }) }) -}) -it('preserves a UTF-8 BOM and rejects split offsets and limits that cannot fit a character', async () => { - const path = join(root, 'unicode.txt') - await writeFile(path, '\uFEFF😀end') - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { - toolName: 'read_local_file', - args: { path, limit: 4 }, + it.for(['file', 'directory'] as const)( + 'rejects %s symlinks outside the import source during manifest and chunk reads', + async (kind, { skip }) => { + if (kind === 'file' && !fileLinks) + skip('creating file symlinks needs Developer Mode or elevation') + const source = join(root, 'selected') + const outside = join(root, 'selected-other') + await mkdir(source) + await mkdir(outside) + await writeFile(join(outside, 'private.txt'), 'outside content') + const target = kind === 'file' ? join(outside, 'private.txt') : outside + await (kind === 'file' + ? symlink(target, join(source, 'link'), 'file') + : linkDirectory(target, join(source, 'link'))) + const authorization = { + toolName: 'import_local_files', + args: { path: source, targetWorkspaceId: 'target' }, } - ) - ).toMatchObject({ ok: true, data: { text: '\uFEFF', nextOffset: 3, truncated: true } }) - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { - toolName: 'read_local_file', - args: { path, offset: 4 }, + expect(await executeLocalFileRequest({ operation: 'manifest' }, authorization)).toMatchObject( + { + ok: false, + error: expect.stringContaining('outside this import source'), + } + ) + const outsideManifest = await executeLocalFileRequest( + { operation: 'manifest' }, + { toolName: 'import_local_files', args: { path: target, targetWorkspaceId: 'target' } } + ) + if (!outsideManifest.ok || outsideManifest.data.kind !== 'manifest') + throw new Error('Expected manifest') + const entry = outsideManifest.data.entries.find((item) => item.kind === 'file')! + expect( + await executeLocalFileRequest( + { + operation: 'chunk', + relativePath: kind === 'file' ? 'link' : 'link/private.txt', + revision: entry.revision, + offset: 0, + }, + authorization + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('outside this import source') }) + } + ) + + it.runIf(fileLinks)( + 'supports internal symlinks and an explicitly selected symlink root, but rejects a retargeted child', + async () => { + const source = join(root, 'selected') + await mkdir(source) + await writeFile(join(source, 'notes.txt'), 'inside') + await symlink(join(source, 'notes.txt'), join(source, 'alias.txt'), 'file') + const selectedAlias = join(root, 'selected-alias') + await linkDirectory(source, selectedAlias) + const authorization = { + toolName: 'import_local_files', + args: { path: selectedAlias, targetWorkspaceId: 'target' }, } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('UTF-8') }) - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { - toolName: 'read_local_file', - args: { path, offset: 3, limit: 1 }, + const manifest = await executeLocalFileRequest({ operation: 'manifest' }, authorization) + if (!manifest.ok || manifest.data.kind !== 'manifest') throw new Error('Expected manifest') + const entry = manifest.data.entries.find((item) => item.relativePath === 'alias.txt')! + const request = { + operation: 'chunk', + relativePath: 'alias.txt', + revision: entry.revision, + offset: 0, } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('limit') }) + expect(await executeLocalFileRequest(request, authorization)).toEqual({ + ok: true, + data: { kind: 'chunk', bytes: new Uint8Array(Buffer.from('inside')), eof: true }, + }) + await writeFile(join(root, 'outside.txt'), 'outside') + await rm(join(source, 'alias.txt')) + await symlink(join(root, 'outside.txt'), join(source, 'alias.txt'), 'file') + expect(await executeLocalFileRequest(request, authorization)).toMatchObject({ + ok: false, + error: expect.stringContaining('outside this import source'), + }) + } + ) + + it('preserves a UTF-8 BOM and rejects split offsets and limits that cannot fit a character', async () => { + const path = join(root, 'unicode.txt') + await writeFile(path, '\uFEFF😀end') + expect( + await executeLocalFileRequest( + { operation: 'read' }, + { + toolName: 'read_local_file', + args: { path, limit: 4 }, + } + ) + ).toMatchObject({ ok: true, data: { text: '\uFEFF', nextOffset: 3, truncated: true } }) + expect( + await executeLocalFileRequest( + { operation: 'read' }, + { + toolName: 'read_local_file', + args: { path, offset: 4 }, + } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('UTF-8') }) + expect( + await executeLocalFileRequest( + { operation: 'read' }, + { + toolName: 'read_local_file', + args: { path, offset: 3, limit: 1 }, + } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('limit') }) + }) }) diff --git a/apps/desktop/src/main/local-files.ts b/apps/desktop/src/main/local-files.ts index 840dd6c10f5..d5f13d94a55 100644 --- a/apps/desktop/src/main/local-files.ts +++ b/apps/desktop/src/main/local-files.ts @@ -1,3 +1,4 @@ +import type { BigIntStats } from 'node:fs' import { lstat, stat } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' import type { @@ -11,8 +12,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { compareStrings } from '@sim/utils/string' import { PDFDocument } from 'pdf-lib' +import { sameFileIdentity } from '@/main/file-identity' import type { LocalFileAccess } from '@/main/local-filesystem' -import { readNativeDirectory } from '@/main/native-directory' const CHUNK_BYTES = 8 * 1024 * 1024 const MAX_ENTRIES = 1000 @@ -23,8 +24,9 @@ export interface LocalFileAuthorization { args: Record } -function revision(info: Awaited>): string { - return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}` +/** Bigint fields, since NTFS file IDs exceed 2^53 and would collide as numbers. */ +function revision(info: BigIntStats): string { + return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}` } function boundedInteger(value: unknown, fallback: number, max: number): number { @@ -43,11 +45,11 @@ function assertImportPath(root: string, candidate: string): void { async function readApprovedDirectory(path: string, access: LocalFileAccess) { const handle = await access.open(path, true) try { - const listing = await readNativeDirectory(handle.fd, MAX_ENTRIES) + const listing = await handle.readDirectory(MAX_ENTRIES) const canonical = await access.resolve(path) - const current = await lstat(canonical) - const opened = await handle.stat() - if (canonical !== path || current.dev !== opened.dev || current.ino !== opened.ino) + const current = await lstat(canonical, { bigint: true }) + const opened = await handle.statBigint() + if (canonical !== path || !sameFileIdentity(current, opened)) throw new Error('The local directory changed while it was being read. Try again.') listing.entries.sort((left, right) => compareStrings(left.name, right.name)) return listing @@ -183,18 +185,19 @@ async function manifest( ) const canonical = await access.resolve(current) assertImportPath(root, canonical) - const info = await stat(canonical) + const info = await stat(canonical, { bigint: true }) + const size = Number(info.size) if (!info.isDirectory() && !info.isFile()) throw new Error(`Cannot import special filesystem entry: ${current}`) if (ancestors.has(canonical)) throw new Error(`Directory link cycle: ${current}`) - if (info.isFile() && info.size > MAX_DESKTOP_IMPORT_FILE_BYTES) + if (info.isFile() && size > MAX_DESKTOP_IMPORT_FILE_BYTES) throw new Error( 'Desktop imports support files up to 64 MB. Use the file uploader for larger files.' ) entries.push({ relativePath: relative(path, current).split(sep).join('/'), kind: info.isDirectory() ? 'directory' : 'file', - size: info.size, + size, revision: revision(info), }) if (info.isDirectory()) { @@ -252,15 +255,16 @@ export async function executeLocalFileRequest( const offset = boundedInteger(request.offset, 0, Number.MAX_SAFE_INTEGER) const file = await access.open(canonical) try { - const info = await file.stat() + const info = await file.statBigint() + const size = Number(info.size) if (!info.isFile() || revision(info) !== request.revision) throw new Error( 'The source file changed during import. Inspect the partial result before retrying.' ) - if (offset > info.size) throw new Error('The requested offset is outside the file.') - const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, info.size - offset)) + if (offset > size) throw new Error('The requested offset is outside the file.') + const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, size - offset)) const { bytesRead } = await file.read(buffer, 0, buffer.length, offset) - if (revision(await file.stat()) !== request.revision) + if (revision(await file.statBigint()) !== request.revision) throw new Error('The source file changed during import.') await access.resolve(child) return { @@ -268,7 +272,7 @@ export async function executeLocalFileRequest( data: { kind: 'chunk', bytes: new Uint8Array(buffer.subarray(0, bytesRead)), - eof: offset + bytesRead >= info.size, + eof: offset + bytesRead >= size, }, } } finally { diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts index 81a1bbc2c6e..e62f8677bb3 100644 --- a/apps/desktop/src/main/local-filesystem.test.ts +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -413,6 +413,28 @@ describe('LocalFilesystemService', () => { } ) + it.each([ + ['an alternate data stream', 'README.md%3Ahidden'], + ['a reserved device name', 'CON'], + ['a reserved device name with an extension', 'src/nul.txt'], + ['a trailing dot Windows would strip', 'README.md.'], + ['a trailing space Windows would strip', 'README.md%20'], + ['a wildcard', 'src/%2A.ts'], + ['a pipe', 'a%7Cb'], + ['a quote', 'a%22b'], + ['an angle bracket', 'a%3Cb'], + ])('refuses %s in a URI on Windows', async (_label, path) => { + const windowsService = new LocalFilesystemService({ + chooseDirectory: async () => root, + platform: 'win32', + }) + const granted = await mount(windowsService) + + expect( + await windowsService.handle({ operation: 'read', uri: `${granted.uri}${path}` }) + ).toMatchObject({ ok: false, code: 'INVALID_URI' }) + }) + it('rejects lexical traversal before URL normalization can reinterpret it', async () => { const granted = await mount(service) const traversal = await service.handle({ diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index f823c60c315..e735e4c7664 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -1,10 +1,9 @@ import { constants, type Dirent } from 'node:fs' -import { type FileHandle, lstat, open, opendir, readFile, realpath, stat } from 'node:fs/promises' +import { type FileHandle, lstat, open, opendir, readFile, stat } from 'node:fs/promises' import { basename, isAbsolute, relative, resolve, sep } from 'node:path' import type { LocalFilesystemData, LocalFilesystemEntry, - LocalFilesystemEntryKind, LocalFilesystemGrepMatch, LocalFilesystemMount, LocalFilesystemResponse, @@ -33,11 +32,15 @@ import { waitForAccountDataMutations, } from '@/main/account-data-generation' import { showShellDialog } from '@/main/dialogs' +import { type FileIdentity, fileEntryKind, sameFileIdentity } from '@/main/file-identity' import type { LocalFilesystemGrantStore, PersistedLocalFilesystemGrant, } from '@/main/local-filesystem-grant-store' +import { localRealpath, NetworkLocationError } from '@/main/local-realpath' import { openNativeFile } from '@/main/native-directory' +import { isWithinRoot } from '@/main/path-containment' +import { isWin32SafeSegment } from '@/main/win32-path' const MAX_URI_LENGTH = 4096 const MAX_LIST_ENTRIES = 500 @@ -95,10 +98,8 @@ const GRANT_SCOPED_OPERATIONS: ReadonlySet = new Set([ 'stat', ]) -interface GrantedMount extends LocalFilesystemMount { +interface GrantedMount extends LocalFilesystemMount, FileIdentity { rootPath: string - dev: bigint - ino: bigint bookmark?: string stopAccessing?: () => void } @@ -114,6 +115,8 @@ interface LocalFilesystemServiceOptions { chooseDirectory?: () => Promise grantStore?: LocalFilesystemGrantStore startAccessingBookmark?: (bookmark: string) => (() => void) | undefined + /** Which platform's file-name rules URI segments must satisfy. Defaults to the host. */ + platform?: NodeJS.Platform } interface SelectedDirectory { @@ -126,38 +129,6 @@ export interface LocalFilesystemToolAuthorization { args: Record } -/** - * Whether a resolved path is the granted root or sits beneath it. - * - * Uses `relative()` rather than prefix arithmetic. The `${root}${sep}` sentinel - * that form needs to reject `/granted-evil` against `/granted` becomes `//` when - * the root IS a separator — and the picker lets the user grant a volume root — - * so every path under a granted `/` was denied while the bare root passed. - * `relative()` has no such edge case: it returns `''` for the root itself and a - * leading `..` segment for anything outside, on both separators. - */ -function isWithinRoot(rootPath: string, candidatePath: string): boolean { - const rel = relative(rootPath, candidatePath) - if (rel === '') return true - // A leading `..` SEGMENT, not the two characters: `..config` is an ordinary - // child name, and matching it as an escape would deny a real dotfile. - if (rel === '..' || rel.startsWith(`..${sep}`)) return false - // A different Windows volume relativizes to an absolute path rather than a - // `..` walk, so it has to be rejected separately. - return !isAbsolute(rel) -} - -function entryKind(entry: { - isFile(): boolean - isDirectory(): boolean - isSymbolicLink(): boolean -}): LocalFilesystemEntryKind { - if (entry.isFile()) return 'file' - if (entry.isDirectory()) return 'directory' - if (entry.isSymbolicLink()) return 'symlink' - return 'other' -} - function encodeUriPath(relativePath: string): string { return relativePath .split('/') @@ -264,6 +235,9 @@ function isBinary(buffer: Uint8Array): boolean { function safeError(error: unknown): LocalFilesystemError { if (error instanceof LocalFilesystemError) return error + if (error instanceof NetworkLocationError) { + return new LocalFilesystemError('ACCESS_DENIED', error.message) + } if (error instanceof DOMException && error.name === 'AbortError') { return new LocalFilesystemError('CANCELLED', 'The local filesystem operation was cancelled.') } @@ -364,10 +338,12 @@ export class LocalFilesystemService { private readonly chooseDirectory: () => Promise private readonly grantStore?: LocalFilesystemGrantStore private readonly startAccessingBookmark: (bookmark: string) => (() => void) | undefined + private readonly platform: NodeJS.Platform private initializePromise?: Promise constructor(options: LocalFilesystemServiceOptions = {}) { this.grantStore = options.grantStore + this.platform = options.platform ?? process.platform this.startAccessingBookmark = options.startAccessingBookmark ?? ((bookmark) => { @@ -651,7 +627,7 @@ export class LocalFilesystemService { async grantDirectory( selected: SelectedDirectory, generation: number, - expected?: { dev: bigint; ino: bigint } + expected?: FileIdentity ): Promise { const stopAccessing = selected.bookmark ? this.startAccessingBookmark(selected.bookmark) @@ -666,7 +642,7 @@ export class LocalFilesystemService { : undefined try { - const rootPath = await realpath(selected.path) + const rootPath = await localRealpath(selected.path) const rootStat = await stat(rootPath, { bigint: true }) if (!isAccountDataGenerationCurrent(generation)) { throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.') @@ -675,12 +651,7 @@ export class LocalFilesystemService { throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The selected item is not a directory.') } - if ( - expected && - (rootPath !== selected.path || - rootStat.dev !== expected.dev || - rootStat.ino !== expected.ino) - ) { + if (expected && (rootPath !== selected.path || !sameFileIdentity(rootStat, expected))) { throw new LocalFilesystemError( 'ACCESS_DENIED', 'The folder changed while awaiting permission. Request access again.' @@ -735,7 +706,7 @@ export class LocalFilesystemService { /** Resolves native tool paths through the same remembered grants as VFS reads. */ async nativeAccess(candidate: string): Promise { - const path = await realpath(candidate) + const path = await localRealpath(candidate) for (const mount of this.mounts.values()) { if (!isWithinRoot(mount.rootPath, path)) continue try { @@ -745,7 +716,7 @@ export class LocalFilesystemService { } const resolveGranted = async (requested: string): Promise => { await this.assertMountCurrent(mount) - const resolved = await realpath(requested) + const resolved = await localRealpath(requested) if (!isWithinRoot(mount.rootPath, resolved)) { throw new LocalFilesystemError( 'ACCESS_DENIED', @@ -785,11 +756,9 @@ export class LocalFilesystemService { const current = this.mounts.get(mount.id) if (!current || current.rootPath !== mount.rootPath) throw mountNotFound() if ( - current.dev !== mount.dev || - current.ino !== mount.ino || + !sameFileIdentity(current, mount) || !root.isDirectory() || - root.dev !== mount.dev || - root.ino !== mount.ino + !sameFileIdentity(root, mount) ) { throw new LocalFilesystemError( 'ACCESS_DENIED', @@ -852,7 +821,7 @@ export class LocalFilesystemService { } const stopAccessing = grant.bookmark ? this.startAccessingBookmark(grant.bookmark) : undefined try { - const rootPath = await realpath(grant.rootPath) + const rootPath = await localRealpath(grant.rootPath) const rootStat = await stat(rootPath, { bigint: true }) if (!isAccountDataGenerationCurrent(generation)) { stopAccessing?.() @@ -976,33 +945,36 @@ export class LocalFilesystemService { const resolved = await this.resolveUri(uri) const handle = await open( resolved.realPath, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + // Windows has no O_NOFOLLOW (the constant is undefined, so this ORs in 0); + // the identity recheck below is what catches a swapped path there. + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK ) try { - const metadata = await handle.stat() + // bigint: NTFS file IDs exceed 2^53, and rounded IDs could compare equal. + const metadata = await handle.stat({ bigint: true }) if (!metadata.isFile()) { throw new LocalFilesystemError('NOT_A_FILE', 'The local path is not a file.') } const verified = await this.resolveUri(uri) - const currentMetadata = await lstat(verified.realPath) + const currentMetadata = await lstat(verified.realPath, { bigint: true }) if ( verified.realPath !== resolved.realPath || !currentMetadata.isFile() || - metadata.dev !== currentMetadata.dev || - metadata.ino !== currentMetadata.ino + !sameFileIdentity(metadata, currentMetadata) ) { throw new LocalFilesystemError( 'ACCESS_DENIED', 'The local file changed while it was being opened. Try again.' ) } - if (metadata.size > maxBytes) { + const size = Number(metadata.size) + if (size > maxBytes) { throw new LocalFilesystemError( 'FILE_TOO_LARGE', `The local file exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MB upload limit.` ) } - return { handle, name: basename(resolved.realPath), size: metadata.size } + return { handle, name: basename(resolved.realPath), size } } catch (error) { await handle.close() throw error @@ -1075,7 +1047,8 @@ export class LocalFilesystemService { decoded === '..' || decoded.includes('/') || (sep === '\\' && decoded.includes('\\')) || - decoded.includes('\0') + decoded.includes('\0') || + (this.platform === 'win32' && !isWin32SafeSegment(decoded)) ) { throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') } @@ -1094,7 +1067,7 @@ export class LocalFilesystemService { 'The requested path is outside the selected folder.' ) } - const realPath = await realpath(lexicalPath) + const realPath = await localRealpath(lexicalPath) if (!isWithinRoot(mount.rootPath, realPath)) { throw new LocalFilesystemError( 'ACCESS_DENIED', @@ -1130,7 +1103,7 @@ export class LocalFilesystemService { return { name: directoryEntry.name, uri: localUri(resolvedPath.mount.id, childRelativePath), - kind: entryKind(directoryEntry), + kind: fileEntryKind(directoryEntry), size: metadata.size, modifiedAt: metadata.mtime.toISOString(), } @@ -1195,7 +1168,7 @@ export class LocalFilesystemService { entries.push({ name: child.name, uri: localUri(resolvedPath.mount.id, mountRelativePath), - kind: entryKind(child), + kind: fileEntryKind(child), size: metadata.size, modifiedAt: metadata.mtime.toISOString(), }) @@ -1485,7 +1458,7 @@ export class LocalFilesystemService { return { name: basename(resolvedPath.lexicalPath) || resolvedPath.mount.name, uri, - kind: entryKind(metadata), + kind: fileEntryKind(metadata), size: metadata.size, modifiedAt: metadata.mtime.toISOString(), } diff --git a/apps/desktop/src/main/local-realpath.test.ts b/apps/desktop/src/main/local-realpath.test.ts new file mode 100644 index 00000000000..0af1da51b7c --- /dev/null +++ b/apps/desktop/src/main/local-realpath.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { localRealpath } from '@/main/local-realpath' + +/* + * How resolving a path on Windows could reach the network before consent, one test each: + * 1. a symlink inside the path targets a share and `realpath` follows it; + * 2. a local link leads to a folder whose own link targets a share; + * 3. the path resolves to a share (a mapped drive), and is granted though it can never be opened; + * 4. the path is itself a share (a folder picked from the network in the native dialog); + * 5. a link loop never ends. + */ + +/** Windows links keyed by path, each mapped to its `readlink` target; every other path is a folder. */ +function windowsFileSystem(links: Record, resolved?: string) { + const followed: string[] = [] + return { + followed, + fs: { + lstat: async (path: string) => ({ isSymbolicLink: () => path in links }), + readlink: async (path: string) => links[path] ?? '', + realpath: async (path: string) => { + followed.push(path) + return resolved ?? path + }, + }, + } +} + +const NETWORK = 'Network locations are not supported' + +describe('localRealpath on Windows', () => { + it('refuses a symlink to a share without following it', async () => { + const { fs, followed } = windowsFileSystem({ 'C:\\repo\\docs': '\\\\attacker\\share' }) + + await expect(localRealpath('C:\\repo\\docs\\notes.txt', 'win32', fs)).rejects.toThrow(NETWORK) + expect(followed).toEqual([]) + }) + + it('refuses a chain of local links that ends at a share', async () => { + const { fs, followed } = windowsFileSystem({ + 'C:\\repo\\vendor': 'D:\\cache', + 'D:\\cache\\pkg': '//attacker/share', + }) + + await expect(localRealpath('C:\\repo\\vendor\\pkg\\a.txt', 'win32', fs)).rejects.toThrow( + NETWORK + ) + expect(followed).toEqual([]) + }) + + it('refuses a path that resolves to a share', async () => { + const { fs } = windowsFileSystem({}, '\\\\server\\share\\projects') + + await expect(localRealpath('Z:\\projects', 'win32', fs)).rejects.toThrow(NETWORK) + }) + + it('refuses a path that is itself a share', async () => { + const { fs, followed } = windowsFileSystem({}) + + await expect(localRealpath('\\\\server\\share', 'win32', fs)).rejects.toThrow(NETWORK) + expect(followed).toEqual([]) + }) + + it('stops a link loop', async () => { + const { fs } = windowsFileSystem({ 'C:\\a': 'C:\\b', 'C:\\b': 'C:\\a' }) + + await expect(localRealpath('C:\\a\\file.txt', 'win32', fs)).rejects.toThrow('levels of links') + }) + + it('resolves a path through local links', async () => { + const { fs } = windowsFileSystem({ 'C:\\Users\\me\\link': 'D:\\work' }, 'D:\\work\\a.txt') + + await expect(localRealpath('C:\\Users\\me\\link\\a.txt', 'win32', fs)).resolves.toBe( + 'D:\\work\\a.txt' + ) + }) +}) diff --git a/apps/desktop/src/main/local-realpath.ts b/apps/desktop/src/main/local-realpath.ts new file mode 100644 index 00000000000..c38cead8fc1 --- /dev/null +++ b/apps/desktop/src/main/local-realpath.ts @@ -0,0 +1,94 @@ +import { lstat, readlink, realpath } from 'node:fs/promises' +import { win32 } from 'node:path' + +/** Refuses a path that resolves to, or passes through a link to, a network location. */ +export class NetworkLocationError extends Error { + constructor() { + super('Network locations are not supported. Choose a file or folder on this computer.') + this.name = 'NetworkLocationError' + } +} + +/** Windows follows at most this many links while resolving a path, as POSIX caps `ELOOP`. */ +const MAX_LINK_HOPS = 40 + +/** The filesystem calls {@link localRealpath} makes. Tests inject them to model Windows links. */ +interface LocalRealpathFileSystem { + lstat: (path: string) => Promise<{ isSymbolicLink(): boolean }> + readlink: (path: string) => Promise + realpath: (path: string) => Promise +} + +/** Looks each call up on `node:fs/promises` when it runs, as every other caller in the shell does. */ +const NODE_FILE_SYSTEM: LocalRealpathFileSystem = { + lstat: (path) => lstat(path), + readlink: (path) => readlink(path), + realpath: (path) => realpath(path), +} + +/** + * A UNC share (`\\host\share`), a `\\?\` or `\\.\` device path, or an NT `\??\` path: any of them + * can name a remote host. Forward slashes count, since Windows accepts them as separators. + */ +function isWin32NetworkPath(path: string): boolean { + return /^[\\/]{2}/.test(path) || path.startsWith('\\??\\') +} + +/** + * Walks `path` one component at a time with `lstat`, which never follows a link, and refuses any + * symlink or junction whose target is a network path. Each local link target is walked in turn, + * so a chain of local links ending at a share is refused too. A missing component throws the same + * error `realpath` would. + */ +async function assertNoNetworkLink(path: string, fs: LocalRealpathFileSystem): Promise { + const absolute = win32.resolve(path) + let current = win32.parse(absolute).root + const pending = absolute.slice(current.length).split(/[\\/]/) + let hops = 0 + while (pending.length > 0) { + const segment = pending.shift() + if (!segment || segment === '.') continue + if (segment === '..') { + current = win32.dirname(current) + continue + } + const next = win32.join(current, segment) + if (!(await fs.lstat(next)).isSymbolicLink()) { + current = next + continue + } + if (++hops > MAX_LINK_HOPS) throw new Error('The path has too many levels of links.') + const target = await fs.readlink(next) + const resolvedTarget = win32.resolve(current, target) + if (isWin32NetworkPath(resolvedTarget)) throw new NetworkLocationError() + current = win32.parse(resolvedTarget).root + pending.unshift(...resolvedTarget.slice(current.length).split(/[\\/]/)) + } +} + +/** + * `realpath` for any path a model or a folder picker supplies, which must stay on this computer. + * + * On Windows, `realpath` follows every symlink in the path, and following one that targets + * `\\host\share` makes Windows authenticate to that host with the user's NTLM hash, so a link + * planted in a repository would leak it before any consent dialog. The components are walked with + * `lstat` first and a link to a network path is refused without being followed. A resolved path + * that is itself a share (a drive letter mapped to one resolves to `\\server\share`) is refused as + * well: the native file backend cannot open it, so granting it would be a dead end. + * + * A process already running as the user could still swap a checked component for a network link + * between the walk and `realpath`; the walk closes the path a planted link takes, not that race. + * Elsewhere it is plain `realpath`. + */ +export async function localRealpath( + path: string, + platform: NodeJS.Platform = process.platform, + fs: LocalRealpathFileSystem = NODE_FILE_SYSTEM +): Promise { + if (platform !== 'win32') return fs.realpath(path) + if (isWin32NetworkPath(path)) throw new NetworkLocationError() + await assertNoNetworkLink(path, fs) + const real = await fs.realpath(path) + if (isWin32NetworkPath(real)) throw new NetworkLocationError() + return real +} diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index 2eb8862dc32..352310d4b59 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -6,7 +6,7 @@ import type { MenuItemConstructorOptions } from 'electron' import type { ConfigStore } from '@/main/config' import { buildMenuTemplate, type MenuDeps } from '@/main/menu' -function makeDeps(origin = 'https://sim.ai'): MenuDeps { +function makeDeps(platform: NodeJS.Platform = 'darwin', origin = 'https://sim.ai'): MenuDeps { return { config: { filePath: '/tmp/settings.json', @@ -29,20 +29,53 @@ function makeDeps(origin = 'https://sim.ai'): MenuDeps { signOut: vi.fn(), checkForUpdates: vi.fn(), openDiagnostics: vi.fn(), + platform, } } -function submenu( - template: MenuItemConstructorOptions[], - label: string -): MenuItemConstructorOptions[] { - return (template.find((item) => item.label === label || item.role === label.toLowerCase()) - ?.submenu ?? []) as MenuItemConstructorOptions[] +/** Every item at any depth, so hidden resource items count too. */ +function flatten(items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] { + return items.flatMap((item) => [ + item, + ...(Array.isArray(item.submenu) ? flatten(item.submenu as MenuItemConstructorOptions[]) : []), + ]) +} + +function accelerators(template: MenuItemConstructorOptions[]): Set { + return new Set( + flatten(template).flatMap((item) => + typeof item.accelerator === 'string' ? [item.accelerator] : [] + ) + ) } describe('buildMenuTemplate', () => { - it('never exposes developer tools in the application menu', () => { - const view = submenu(buildMenuTemplate(makeDeps()), 'View') - expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false) + it.each(['darwin', 'win32'] as const)('never exposes developer tools on %s', (platform) => { + const items = flatten(buildMenuTemplate(makeDeps(platform))) + expect(items.some((item) => item.role === 'toggleDevTools')).toBe(false) + }) + + it('keeps every macOS shortcut working on Windows', () => { + const windows = accelerators(buildMenuTemplate(makeDeps('win32'))) + for (const accelerator of accelerators(buildMenuTemplate(makeDeps('darwin')))) { + expect(windows).toContain(accelerator) + } + expect(windows).toContain('Alt+Left') + expect(windows).toContain('Alt+Right') + }) + + it('leaves out windowMenu on Windows, whose Close item would steal Ctrl+W from Close Tab', () => { + const roles = flatten(buildMenuTemplate(makeDeps('win32'))).map((item) => item.role) + expect(roles).not.toContain('windowMenu') + }) + + it('binds each Windows accelerator to exactly one item', () => { + const counts = new Map() + for (const item of flatten(buildMenuTemplate(makeDeps('win32')))) { + if (typeof item.accelerator === 'string') { + counts.set(item.accelerator, (counts.get(item.accelerator) ?? 0) + 1) + } + } + expect([...counts].filter(([, count]) => count > 1)).toEqual([]) }) }) diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index b6bfb58fcf7..72e3604a2fb 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -36,14 +36,44 @@ export interface MenuDeps { signOut: () => void checkForUpdates: () => void openDiagnostics: () => void + /** Injectable so each platform's template is testable on any host. */ + platform?: NodeJS.Platform +} + +type MenuClick = NonNullable + +/** The items both platforms' menus are assembled from. */ +interface SharedMenuItems { + viewSubmenu: MenuItemConstructorOptions[] + helpItems: MenuItemConstructorOptions[] + resourceFileItems: MenuItemConstructorOptions[] + aboutItem: MenuItemConstructorOptions + settingsItem: MenuItemConstructorOptions + serverItem: MenuItemConstructorOptions + checkForUpdatesItem: MenuItemConstructorOptions + signOutItem: MenuItemConstructorOptions + folderAccessItem: MenuItemConstructorOptions + newWindowItem: MenuItemConstructorOptions + newChatItem: MenuItemConstructorOptions + closeWindowItem: MenuItemConstructorOptions + goBack: MenuClick + goForward: MenuClick } /** - * Builds the role-based macOS menu. Edit roles are load-bearing — without - * them copy/paste/undo silently fail in web inputs. Zoom items are custom so - * the zoom level persists across launches. + * Builds the application menu: the macOS app-menu shape, or on Windows a + * File/Edit/View/Help bar. Edit roles are load-bearing — without them + * copy/paste/undo silently fail in web inputs on macOS. Zoom items are custom + * so the zoom level persists across launches. */ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { + const items = sharedMenuItems(deps) + return (deps.platform ?? process.platform) === 'win32' + ? buildWindowsMenu(items) + : buildMacMenu(items) +} + +function sharedMenuItems(deps: MenuDeps): SharedMenuItems { /** Utility windows must not redirect resource commands into the hidden main window. */ const focusedMainOrFallback = (focusedWindow: unknown): BrowserWindow | null => { if (focusedWindow instanceof BrowserWindow) { @@ -182,26 +212,242 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] ] viewSubmenu.push({ role: 'togglefullscreen' }) + const aboutItem: MenuItemConstructorOptions = { + label: `About ${app.name}`, + click: () => { + void showShellDialog({ + title: `About ${app.name}`, + type: 'info', + message: app.name, + detail: `Version ${app.getVersion()}`, + buttons: ['OK'], + }) + }, + } + const settingsItem: MenuItemConstructorOptions = { + label: 'Settings…', + accelerator: 'CmdOrCtrl+,', + click: deps.openSettings, + } + const serverItem: MenuItemConstructorOptions = { + label: 'Server…', + click: deps.openServerSettings, + } + const checkForUpdatesItem: MenuItemConstructorOptions = { + label: 'Check for Updates…', + click: deps.checkForUpdates, + } + const signOutItem: MenuItemConstructorOptions = { label: 'Sign Out', click: deps.signOut } + const folderAccessItem: MenuItemConstructorOptions = { + label: 'Folder Access…', + click: (_item, focusedWindow) => { + const win = focusedWindowOrMain(focusedWindow) + if (win) deps.openFolderAccess(win) + }, + } + const newWindowItem: MenuItemConstructorOptions = { + label: 'New Window', + accelerator: 'CmdOrCtrl+Shift+N', + click: deps.newWindow, + } + const newChatItem: MenuItemConstructorOptions = { + label: 'New Chat', + accelerator: 'CmdOrCtrl+N', + click: deps.newChat, + } + const closeWindowItem: MenuItemConstructorOptions = { + label: 'Close Window', + accelerator: 'CmdOrCtrl+Shift+W', + click: (_item, focusedWindow) => { + focusedWindowOrMain(focusedWindow)?.close() + }, + } + const helpItems: MenuItemConstructorOptions[] = [ + { + label: 'Sim Documentation', + click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), + }, + // Omitted for a self-hosted shell, like the offline page's status + // button — see isSimCloudOrigin. + ...(isSimCloudOrigin(deps.config.getOrigin()) + ? [ + { + label: 'Sim Status', + click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), + }, + ] + : []), + { type: 'separator' }, + { label: 'Show Diagnostic Logs', click: deps.openDiagnostics }, + ] + + const resourceFileItems: MenuItemConstructorOptions[] = [ + // Panel-scoped shortcuts stay out of the visible File menu; hidden items' + // accelerators still fire on both platforms. Tab items sit flat because a + // hidden submenu's children do not reliably register accelerators. + { + label: 'New Tab', + accelerator: 'CmdOrCtrl+T', + visible: false, + click: (_item, focusedWindow) => { + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'new-tab') + }, + }, + { + label: 'Reopen Closed Tab', + accelerator: 'CmdOrCtrl+Shift+T', + visible: false, + click: (_item, focusedWindow) => { + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'reopen-closed-tab') + }, + }, + { + label: 'Focus Address Bar', + accelerator: 'CmdOrCtrl+L', + visible: false, + click: resourceShortcut('focus-omnibox'), + }, + { + label: 'Next Tab', + accelerator: 'Ctrl+Tab', + visible: false, + click: resourceShortcut('next-tab'), + }, + { + label: 'Previous Tab', + accelerator: 'Ctrl+Shift+Tab', + visible: false, + click: resourceShortcut('previous-tab'), + }, + ...numberedTabItems, + { + label: 'Close Tab', + accelerator: 'CmdOrCtrl+W', + visible: false, + click: (_item, focusedWindow) => { + const win = focusedWindowOrMain(focusedWindow) + if (!win) return + if (deps.isMainWindow(win) && deps.handleFocusedResourceShortcut(win, 'close-tab')) { + return + } + win.close() + }, + }, + ] + + return { + viewSubmenu, + helpItems, + resourceFileItems, + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, + folderAccessItem, + newWindowItem, + newChatItem, + closeWindowItem, + goBack: traverseHistory('back'), + goForward: traverseHistory('forward'), + } +} + +function buildWindowsMenu({ + viewSubmenu, + helpItems, + resourceFileItems, + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, + folderAccessItem, + newWindowItem, + newChatItem, + closeWindowItem, + goBack, + goForward, +}: SharedMenuItems): MenuItemConstructorOptions[] { return [ { - label: app.name, + label: '&File', + submenu: [ + newWindowItem, + newChatItem, + { type: 'separator' }, + folderAccessItem, + settingsItem, + serverItem, + { type: 'separator' }, + signOutItem, + { type: 'separator' }, + closeWindowItem, + ...resourceFileItems, + { type: 'separator' }, + { role: 'quit', label: 'Exit' }, + ], + }, + { role: 'editMenu' }, + { + label: '&View', submenu: [ + ...viewSubmenu, + // Alt+Left/Right are the Windows browser-history keys; hidden so View keeps one row each. { - label: `About ${app.name}`, - click: () => { - void showShellDialog({ - title: `About ${app.name}`, - type: 'info', - message: app.name, - detail: `Version ${app.getVersion()}`, - buttons: ['OK'], - }) - }, + label: 'Back', + accelerator: 'Alt+Left', + visible: false, + click: goBack, }, - { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, - { label: 'Server…', click: deps.openServerSettings }, - { label: 'Check for Updates…', click: deps.checkForUpdates }, - { label: 'Sign Out', click: deps.signOut }, + { + label: 'Forward', + accelerator: 'Alt+Right', + visible: false, + click: goForward, + }, + ], + }, + // No windowMenu role: off macOS it carries a Close item bound to Ctrl+W, + // which would steal the hidden Close Tab accelerator. + { + label: '&Help', + submenu: [ + ...helpItems, + { type: 'separator' }, + checkForUpdatesItem, + { type: 'separator' }, + aboutItem, + ], + }, + ] +} + +function buildMacMenu({ + viewSubmenu, + helpItems, + resourceFileItems, + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, + folderAccessItem, + newWindowItem, + newChatItem, + closeWindowItem, +}: SharedMenuItems): MenuItemConstructorOptions[] { + return [ + { + label: app.name, + submenu: [ + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, @@ -213,113 +459,19 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'File', submenu: [ - { - label: 'Folder Access…', - click: (_item, focusedWindow) => { - const win = focusedWindowOrMain(focusedWindow) - if (win) deps.openFolderAccess(win) - }, - }, + folderAccessItem, { type: 'separator' }, - { - label: 'New Window', - accelerator: 'CmdOrCtrl+Shift+N', - click: deps.newWindow, - }, - { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: deps.newChat }, + newWindowItem, + newChatItem, { type: 'separator' }, - { - label: 'Close Window', - accelerator: 'CmdOrCtrl+Shift+W', - click: (_item, focusedWindow) => { - focusedWindowOrMain(focusedWindow)?.close() - }, - }, - /** - * Resource-scoped shortcuts: these act on whichever Browser/Terminal - * panel is focused, not on the app, so they stay out of the visible - * File menu. The accelerators still fire — macOS registers a hidden - * item's accelerator (`acceleratorWorksWhenHidden` defaults to true). - * The numbered tab items sit flat here rather than under a "Select - * Tab" submenu because children of a hidden submenu do not reliably - * register their accelerators. - */ - { - label: 'New Tab', - accelerator: 'CmdOrCtrl+T', - visible: false, - click: (_item, focusedWindow) => { - const win = focusedMainOrFallback(focusedWindow) - if (win) deps.handleFocusedResourceShortcut(win, 'new-tab') - }, - }, - { - label: 'Reopen Closed Tab', - accelerator: 'CmdOrCtrl+Shift+T', - visible: false, - click: (_item, focusedWindow) => { - const win = focusedMainOrFallback(focusedWindow) - if (win) deps.handleFocusedResourceShortcut(win, 'reopen-closed-tab') - }, - }, - { - label: 'Focus Address Bar', - accelerator: 'CmdOrCtrl+L', - visible: false, - click: resourceShortcut('focus-omnibox'), - }, - { - label: 'Next Tab', - accelerator: 'Ctrl+Tab', - visible: false, - click: resourceShortcut('next-tab'), - }, - { - label: 'Previous Tab', - accelerator: 'Ctrl+Shift+Tab', - visible: false, - click: resourceShortcut('previous-tab'), - }, - ...numberedTabItems, - { - label: 'Close Tab', - accelerator: 'CmdOrCtrl+W', - visible: false, - click: (_item, focusedWindow) => { - const win = focusedWindowOrMain(focusedWindow) - if (!win) return - if (deps.isMainWindow(win) && deps.handleFocusedResourceShortcut(win, 'close-tab')) { - return - } - win.close() - }, - }, + closeWindowItem, + ...resourceFileItems, ], }, { role: 'editMenu' }, { label: 'View', submenu: viewSubmenu }, { role: 'windowMenu' }, - { - role: 'help', - submenu: [ - { - label: 'Sim Documentation', - click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), - }, - // Omitted for a self-hosted shell, like the offline page's status - // button — see isSimCloudOrigin. - ...(isSimCloudOrigin(deps.config.getOrigin()) - ? [ - { - label: 'Sim Status', - click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), - }, - ] - : []), - { type: 'separator' }, - { label: 'Show Diagnostic Logs', click: deps.openDiagnostics }, - ], - }, + { role: 'help', submenu: helpItems }, ] } diff --git a/apps/desktop/src/main/native-directory.ts b/apps/desktop/src/main/native-directory.ts index 224d16b37dd..46c402cd4f8 100644 --- a/apps/desktop/src/main/native-directory.ts +++ b/apps/desktop/src/main/native-directory.ts @@ -1,14 +1,37 @@ -import { fstat, read } from 'node:fs' +import { type BigIntStats, fstat, read, type Stats } from 'node:fs' import { join } from 'node:path' import { promisify } from 'node:util' import type { DesktopLocalFileRead } from '@sim/desktop-bridge' import { app } from 'electron' +import type { FileIdentity } from '@/main/file-identity' +import { openWin32NativeFile } from '@/main/win32-native-files' -interface NativeDirectoryListing { +/** At most `limit` entries of one directory, and whether more were left unread. */ +export interface NativeDirectoryListing { entries: NonNullable truncated: boolean } +/** A file or directory opened beneath the identity of a granted directory. */ +export interface NativeFileHandle { + readonly fd: number + stat: () => Promise + /** {@link stat} with bigint fields, for comparing identities: NTFS file IDs exceed 2^53. */ + statBigint: () => Promise + read: ( + buffer: Buffer, + offset: number, + length: number, + position: number + ) => Promise<{ bytesRead: number }> + /** + * Lists the opened directory, scanning at most `limit + 1` names. Symlinks + * (and, on Windows, junctions) are reported as `symlink`, never followed. + */ + readDirectory: (limit: number) => Promise + close: () => Promise +} + interface NativeDirectoryBridge { closeFile: (descriptor: number) => Promise readDirectory: (descriptor: number, limit: number) => Promise @@ -32,13 +55,17 @@ function nativeBridge(): NativeDirectoryBridge { return bridge } -/** Owns a descriptor opened beneath the identity of a granted directory. */ -export async function openNativeFile( +/** + * Opens through the POSIX native module, which walks each component with + * `openat(O_NOFOLLOW)` from a descriptor verified against the grant identity, + * and enumerates with `fdopendir` on the opened descriptor. + */ +export async function openPosixNativeFile( root: string, relativePath: string, - identity: { dev: bigint; ino: bigint }, + identity: FileIdentity, directory: boolean -) { +): Promise { let descriptor = await nativeBridge().openApproved( root, relativePath, @@ -51,8 +78,10 @@ export async function openNativeFile( return descriptor }, stat: () => statDescriptor(descriptor), - read: (buffer: Buffer, offset: number, length: number, position: number) => + statBigint: () => statDescriptor(descriptor, { bigint: true }), + read: (buffer, offset, length, position) => readDescriptor(descriptor, buffer, offset, length, position), + readDirectory: (limit) => nativeBridge().readDirectory(descriptor, limit), close: async () => { if (descriptor < 0) return const closing = descriptor @@ -62,10 +91,12 @@ export async function openNativeFile( } } -/** Enumerates the already-validated descriptor without resolving a pathname again. */ -export function readNativeDirectory( - descriptor: number, - limit: number -): Promise { - return nativeBridge().readDirectory(descriptor, limit) -} +/** + * Opens `relativePath` (in the host's separators) beneath `root`. Refuses + * unless `root` still has the granted `identity` and the target is a regular + * file, or a directory when `directory` is set. Node has no descriptor-relative + * open on Windows, so Windows uses {@link openWin32NativeFile}; macOS and Linux + * use the POSIX native module. + */ +export const openNativeFile = + process.platform === 'win32' ? openWin32NativeFile : openPosixNativeFile diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts index 82fbfce631b..d1bb2e91fae 100644 --- a/apps/desktop/src/main/observability.test.ts +++ b/apps/desktop/src/main/observability.test.ts @@ -26,31 +26,37 @@ describe('scrubUrl', () => { }) describe('createEventLog', () => { - it('creates its directory and log with private permissions', () => { - const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) - const dir = join(root, 'logs') - const events = createEventLog(dir) - events.record('app_launch') + it.skipIf(process.platform === 'win32')( + 'creates its directory and log with private permissions', + () => { + const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) + const dir = join(root, 'logs') + const events = createEventLog(dir) + events.record('app_launch') - expect(statSync(dir).mode & 0o777).toBe(0o700) - expect(statSync(events.filePath).mode & 0o777).toBe(0o600) - }) + expect(statSync(dir).mode & 0o777).toBe(0o700) + expect(statSync(events.filePath).mode & 0o777).toBe(0o600) + } + ) - it('reports permission failures without exposing local paths or OS errors', () => { - const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) - const overlongDir = join(root, 'x'.repeat(300)) + it.skipIf(process.platform === 'win32')( + 'reports permission failures without exposing local paths or OS errors', + () => { + const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) + const overlongDir = join(root, 'x'.repeat(300)) - const events = createEventLog(overlongDir) - events.record('app_launch') + const events = createEventLog(overlongDir) + events.record('app_launch') - expect(mockLogger.warn.mock.calls).toEqual([ - ['Could not apply private desktop event-log permissions', { target: 'directory' }], - ['Could not apply private desktop event-log permissions', { target: 'current-log' }], - ['Could not apply private desktop event-log permissions', { target: 'rotated-log' }], - ]) - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(root) - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('ENAMETOOLONG') - }) + expect(mockLogger.warn.mock.calls).toEqual([ + ['Could not apply private desktop event-log permissions', { target: 'directory' }], + ['Could not apply private desktop event-log permissions', { target: 'current-log' }], + ['Could not apply private desktop event-log permissions', { target: 'rotated-log' }], + ]) + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(root) + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('ENAMETOOLONG') + } + ) }) describe('installMainProcessFailureObservers', () => { diff --git a/apps/desktop/src/main/path-containment.ts b/apps/desktop/src/main/path-containment.ts new file mode 100644 index 00000000000..f8bb4621e41 --- /dev/null +++ b/apps/desktop/src/main/path-containment.ts @@ -0,0 +1,21 @@ +import { isAbsolute, relative, sep } from 'node:path' + +/** + * Whether a resolved path is the granted root or sits beneath it. + * + * Uses `relative()` rather than a `${root}${sep}` prefix check, which breaks + * when the root is itself a separator (the picker can grant a volume root). + * `relative()` returns `''` for the root itself and a + * leading `..` segment for anything outside, on both separators. On Windows it + * compares case-insensitively, as NTFS resolves names. + */ +export function isWithinRoot(rootPath: string, candidatePath: string): boolean { + const rel = relative(rootPath, candidatePath) + if (rel === '') return true + // A leading `..` SEGMENT, not the two characters: `..config` is an ordinary + // child name, and matching it as an escape would deny a real dotfile. + if (rel === '..' || rel.startsWith(`..${sep}`)) return false + // A different Windows volume relativizes to an absolute path rather than a + // `..` walk, so it has to be rejected separately. + return !isAbsolute(rel) +} diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts index ef56e590fd0..6c61dc50de3 100644 --- a/apps/desktop/src/main/server-window.ts +++ b/apps/desktop/src/main/server-window.ts @@ -159,6 +159,9 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { // ways out must therefore work without the page: Escape is handled here, // and a page that fails to load closes the window instead of leaving a // blank sheet nothing can dismiss. + // Windows attaches the app menu to every window; its accelerators would + // otherwise act on (and close) this sheet behind the page's back. + if (process.platform === 'win32') win.removeMenu() attachShellTheme(win) const opened = win let closed = false diff --git a/apps/desktop/src/main/shell-window.ts b/apps/desktop/src/main/shell-window.ts index af74d8811b3..7f7ebee545d 100644 --- a/apps/desktop/src/main/shell-window.ts +++ b/apps/desktop/src/main/shell-window.ts @@ -1,5 +1,5 @@ import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent } from 'electron' -import { screen } from 'electron' +import { app, screen } from 'electron' /** Only the exact bundled top-level document may operate its own host window. */ export function isShellWindowSender( @@ -35,3 +35,23 @@ export function attachShellWindowSizing( } }) } + +/** + * Raises a window above whatever app currently has the foreground, restoring it if minimized — + * the user just finished something in the browser or relaunched Sim and expects it in front. + * macOS activates the app (`steal` is macOS-only). Windows ignores focus requests from a + * background process and only flashes the taskbar button, so a momentary always-on-top lift puts + * the window in front first. + */ +export function bringWindowToFront(win: BrowserWindow): void { + if (win.isMinimized()) win.restore() + win.show() + if (process.platform === 'win32') { + win.setAlwaysOnTop(true) + win.focus() + win.setAlwaysOnTop(false) + return + } + win.focus() + app.focus({ steal: true }) +} diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index 9aafbf7ff2c..dee5a6125fa 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -37,11 +37,7 @@ import { isResourceTabSelectionShortcut, resourceTabTargetIndex, } from '@/main/resource-shortcuts' -import { - type ForegroundProcessGroup, - readForegroundProcessGroup, - signalProcessGroup, -} from '@/main/terminal/process-group' +import { osProcessGroups, type TerminalProcessGroups } from '@/main/terminal/process-group' import type { RunLedger } from '@/main/terminal/run-ledger' import { elide, type ShellStartupBounds, TerminalSession } from '@/main/terminal/session' import { @@ -164,38 +160,28 @@ export interface TerminalServiceOptions { */ loadCwd?(): string | undefined canSpawn?(): boolean - /** Reads and signals a terminal's foreground process group; the OS's by default. */ + /** Reads and signals a terminal's foreground command; the OS's by default. */ processGroups?: TerminalProcessGroups /** Where tagged tmux runs are recorded, so a later process can still stop them. */ runLedger?: RunLedger } -interface TerminalProcessGroups { - foreground(shellPid: number): Promise - signal(pgid: number, signal: NodeJS.Signals): void -} - -const OS_PROCESS_GROUPS: TerminalProcessGroups = { - foreground: readForegroundProcessGroup, - signal: signalProcessGroup, +/** A command holding a terminal's foreground. */ +export interface RunningTerminalCommand { + /** Its command line, `''` when the shell did not announce it. */ + command: string + /** The program name of the shell it was typed into (`zsh`, `pwsh`), whose syntax names its program. */ + shell: string } /** The terminal work a restart would cut off. */ export interface TerminalWork { - /** The command line holding each busy terminal's foreground, `''` when unannounced. */ - commands: string[] + /** The command holding each busy terminal's foreground. */ + commands: RunningTerminalCommand[] /** Terminals whose foreground could not be read, which may be running anything. */ uncheckedCount: number } -async function foregroundGroupId( - groups: TerminalProcessGroups, - shellPid: number -): Promise { - const foreground = await groups.foreground(shellPid) - return foreground.kind === 'group' ? foreground.pgid : null -} - /** * One tool call's Stop: remembered if it arrives before the call's command starts, and handed to * whatever is running once it does. @@ -235,6 +221,15 @@ const TERMINAL_CHANGING_OPERATIONS: ReadonlySet = new Set([ */ const STOP_ESCALATION_MS = 2_000 +/** Refuses a run into a terminal whose foreground is still taken. */ +function throwIfBusy(session: TerminalSession): void { + if (!session.isBusy) return + throw new TerminalError( + 'BUSY', + `"${session.foreground}" is still running in that terminal. Poll it with the read operation, stop it with kill, or open another terminal with new.` + ) +} + export class TerminalService { /** Insertion-ordered, which is also the tab order the user sees. */ private readonly sessions = new Map() @@ -324,21 +319,29 @@ export class TerminalService { } /** - * What each terminal is running. A shell without integration (fish, sh) announces nothing, so - * the OS's foreground process group answers for it; one the OS could not be asked about is - * reported as unchecked rather than idle. + * What each terminal is running. A shell without integration (fish, sh, cmd) announces nothing, + * so the OS answers for it with whatever holds its foreground since it spawned; one the OS could + * not be asked about is reported as unchecked rather than idle. */ async runningWork(): Promise { - const groups = this.options.processGroups ?? OS_PROCESS_GROUPS + const groups = this.options.processGroups ?? osProcessGroups const perSession = await Promise.all( [...this.sessions.values()].map( - async (session): Promise<{ command: string | null; unchecked: boolean }> => { - if (session.isBusy) return { command: session.foreground ?? '', unchecked: false } + async ( + session + ): Promise<{ command: RunningTerminalCommand | null; unchecked: boolean }> => { + const running = (command: string) => ({ command, shell: session.shellProgram }) + if (session.isBusy) + return { command: running(session.foreground ?? ''), unchecked: false } if (session.hasShellIntegration) return { command: null, unchecked: false } - const foreground = await groups.foreground(session.pid) + const foreground = await groups.foreground( + session.pid, + session.spawnedAt, + session.shellReadyAt + ) return { - command: foreground.kind === 'group' ? '' : null, - unchecked: foreground.kind === 'unknown', + command: foreground?.length ? running('') : null, + unchecked: foreground === null, } } ) @@ -648,7 +651,7 @@ export class TerminalService { shortcut: FocusedResourceShortcut, ownerWindow: BrowserWindow | null, emitRendererCommand: (command: TerminalShortcutCommand, terminalId: string) => void, - confirmCloseRunning?: (running: string) => boolean | Promise + confirmCloseRunning?: (running: string, shell: string) => boolean | Promise ): boolean { // Hard reload and history have no terminal meaning — leave them to the Browser or shell. if ( @@ -713,13 +716,13 @@ export class TerminalService { private async confirmCloseRunningTerminal( terminal: TerminalSession, running: string, - confirm: (running: string) => boolean | Promise + confirm: (running: string, shell: string) => boolean | Promise ): Promise { const id = this.activeId if (!id || this.pendingCloseConfirmations.has(id)) return this.pendingCloseConfirmations.add(id) try { - if (!(await confirm(running))) return + if (!(await confirm(running, terminal.shellProgram))) return if (this.sessions.get(id) !== terminal) return if (terminal.isBusy && (terminal.foreground ?? 'A process') !== running) return this.closeTerminal(id) @@ -1012,28 +1015,63 @@ export class TerminalService { } /** - * Interrupts the command a run started. Escalation is bound to that command's own process - * group, read while it still holds the foreground, and each signal is sent only while the same - * call and the same group still hold it, so a command the user starts meanwhile is never hit. + * Interrupts the command a run started, then escalates against that command's own processes + * only, each signal going to those of them still running while the same call does, so a command + * the user starts meanwhile is never hit. Where the OS reads them is the platform's call + * ({@link TerminalProcessGroups.readBeforeInterrupt}): before Ctrl-C on macOS and Linux, so the + * shell's prompt work that takes the tty once the command ends is never mistaken for it; only + * once Ctrl-C has failed on Windows, where the lookup starts PowerShell. */ private async stopCommand(session: TerminalSession, toolCallId: string): Promise { + const startedAt = session.agentCommandStartedAt + if (session.agentCommandToolCallId !== toolCallId || startedAt === null) return + const groups = this.options.processGroups ?? osProcessGroups + const readCommand = async () => + (await groups.foreground(session.pid, startedAt, session.shellReadyAt)) ?? [] + let command = groups.readBeforeInterrupt ? await readCommand() : undefined if (session.agentCommandToolCallId !== toolCallId) return - const groups = this.options.processGroups ?? OS_PROCESS_GROUPS - const pgid = await foregroundGroupId(groups, session.pid) - if (session.agentCommandToolCallId !== toolCallId) return - session.kill('SIGINT') - for (const escalation of ['SIGTERM', 'SIGKILL'] as const) { + session.interrupt() + for (const escalation of groups.escalations) { if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return - if (pgid === null || (await foregroundGroupId(groups, session.pid)) !== pgid) return + const remaining = + command === undefined + ? await readCommand() + : await groups.stillRunning(session.pid, command) + command ??= remaining + if (remaining.length === 0) return if (session.agentCommandToolCallId !== toolCallId) return logger.info('Stopped command ignored the previous signal; escalating', { toolCallId, signal: escalation, }) - groups.signal(pgid, escalation) + groups.signal(remaining, escalation) } } + /** + * Sends a terminating signal to the command holding a plain shell's foreground, never the shell: + * the tool stops what is running, and the shell is the user's tab. On macOS and Linux that is + * the tty's foreground process group; on Windows, which has no signals, each process the shell + * started since the command began is ended with its tree. `signalled` is false when nothing but + * the shell held the foreground, or the OS could not say what did. + */ + private async terminateForeground( + session: TerminalSession, + signal: 'SIGTERM' | 'SIGKILL', + stopped: AbortSignal + ): Promise<{ signal: string; terminalId: string; signalled: boolean }> { + const groups = this.options.processGroups ?? osProcessGroups + const command = + (await groups.foreground( + session.pid, + session.foregroundStartedAt ?? session.spawnedAt, + session.shellReadyAt + )) ?? [] + if (stopped.aborted) throw stoppedBeforeStart() + if (command.length > 0) groups.signal(command, signal) + return { signal, terminalId: session.terminalId, signalled: command.length > 0 } + } + private async dispatch( toolCallId: string, operation: TerminalOperation, @@ -1157,8 +1195,11 @@ export class TerminalService { await sendKey(target, signal === 'SIGKILL' ? 'C-\\' : 'C-c', session.env) return { signal, terminalId: session.terminalId, pane: target } } - session.kill(signal) - return { signal, terminalId: session.terminalId } + if (signal === 'SIGINT') { + session.interrupt() + return { signal, terminalId: session.terminalId } + } + return await this.terminateForeground(session, signal, latch.signal) } default: throw new TerminalError('INVALID_REQUEST', `Unknown terminal operation: ${operation}`) @@ -1440,12 +1481,10 @@ export class TerminalService { if (!session.hasShellIntegration) { await this.awaitShellStartup(session, latch) } - if (session.isBusy) { - throw new TerminalError( - 'BUSY', - `"${session.foreground}" is still running in that terminal. Poll it with the read operation, stop it with kill, or open another terminal with new.` - ) - } + // Checked before the wait too: a busy shell draws no prompt, so the wait would only delay BUSY. + throwIfBusy(session) + await session.waitForPromptDrawn(latch.signal) + throwIfBusy(session) if (latch.signal.aborted) throw stoppedBeforeStart() latch.stopRunning = () => this.stopCommand(session, toolCallId) diff --git a/apps/desktop/src/main/terminal/process-group.test.ts b/apps/desktop/src/main/terminal/process-group.test.ts index efefec57e4b..ff651300c32 100644 --- a/apps/desktop/src/main/terminal/process-group.test.ts +++ b/apps/desktop/src/main/terminal/process-group.test.ts @@ -1,15 +1,93 @@ import { spawn } from 'node:child_process' import { once } from 'node:events' import { describe, expect, it } from 'vitest' -import { readForegroundProcessGroup } from '@/main/terminal/process-group' +import { + osProcessGroups, + type WindowsProcess, + windowsProcessGroups, +} from '@/main/terminal/process-group' -describe.skipIf(process.platform === 'win32')('readForegroundProcessGroup', () => { +const SHELL_PID = 100 +const COMMAND_TYPED_AT = 1_000_000 +/** When the pty's shell started, well before the command. */ +const SHELL_STARTED_AT = COMMAND_TYPED_AT - 600_000 +/** When the shell began running its startup file, after any launcher handed over to it. */ +const SHELL_READY_AT = SHELL_STARTED_AT + 500 + +/** Reads the foreground from `processes`, alongside the shell itself. */ +async function foreground(processes: WindowsProcess[] | null): Promise { + const table = processes && [ + { pid: SHELL_PID, parentPid: 1, createdAt: SHELL_STARTED_AT }, + ...processes, + ] + const ids = await windowsProcessGroups(async () => table).foreground( + SHELL_PID, + COMMAND_TYPED_AT, + SHELL_READY_AT + ) + return ids && [...ids].sort() +} + +describe.skipIf(process.platform === 'win32')('the POSIX foreground process group', () => { it('reads a shell that has already exited as idle, not unknown', async () => { const child = spawn('true') await once(child, 'exit') const exitedPid = child.pid if (exitedPid === undefined) throw new Error('The child never started') - await expect(readForegroundProcessGroup(exitedPid)).resolves.toEqual({ kind: 'shell' }) + await expect(osProcessGroups.foreground(exitedPid, 0, null)).resolves.toEqual([]) + }) +}) + +describe('the Windows foreground command', () => { + it('is what the shell started since the command was typed, never earlier background work', async () => { + await expect( + foreground([ + { pid: 201, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT - 60_000 }, + { pid: 202, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT }, + { pid: 203, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT + 15 }, + { pid: 300, parentPid: 1, createdAt: COMMAND_TYPED_AT + 15 }, + ]) + ).resolves.toEqual([202, 203]) + await expect( + foreground([{ pid: 201, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT - 60_000 }]) + ).resolves.toEqual([]) + }) + + it('finds the command behind a launcher, and names only the top of its tree', async () => { + // Git for Windows' bin\bash.exe holds the pty and starts the real usr\bin\bash.exe. + await expect( + foreground([ + { pid: 150, parentPid: SHELL_PID, createdAt: SHELL_STARTED_AT + 30 }, + { pid: 201, parentPid: 150, createdAt: COMMAND_TYPED_AT - 60_000 }, + { pid: 202, parentPid: 150, createdAt: COMMAND_TYPED_AT + 5 }, + { pid: 210, parentPid: 202, createdAt: COMMAND_TYPED_AT + 40 }, + ]) + ).resolves.toEqual([202]) + }) + + it('spares a worker that earlier background work starts while the command runs', async () => { + // pwsh runs a program as its own child; Git Bash forks itself under the real shell for a job. + // Started the moment the shell was ready, the server is still no launcher. + await expect( + foreground([ + { pid: 201, parentPid: SHELL_PID, createdAt: SHELL_READY_AT + 100 }, + { pid: 211, parentPid: 201, createdAt: COMMAND_TYPED_AT + 500 }, + { pid: 202, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT + 5 }, + ]) + ).resolves.toEqual([202]) + await expect( + foreground([ + { pid: 150, parentPid: SHELL_PID, createdAt: SHELL_STARTED_AT + 30 }, + { pid: 201, parentPid: 150, createdAt: SHELL_READY_AT + 100 }, + { pid: 205, parentPid: 201, createdAt: SHELL_READY_AT + 100 }, + { pid: 211, parentPid: 205, createdAt: COMMAND_TYPED_AT + 500 }, + { pid: 202, parentPid: 150, createdAt: COMMAND_TYPED_AT + 5 }, + ]) + ).resolves.toEqual([202]) + }) + + it('is unknown, never idle, when the process table cannot be read', async () => { + await expect(foreground(null)).resolves.toBeNull() }) }) diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index 4c040383cb2..616492e601a 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -1,48 +1,236 @@ /** - * The process group a terminal's tty currently delivers keyboard signals to: the whole pipeline - * the user would stop with Ctrl-C, including its children, and never the shell itself. + * The processes running a terminal's foreground command: the whole pipeline the user would stop + * with Ctrl-C, including its children, and never the shell itself. + * + * On macOS and Linux that is the tty's foreground process group. Windows has no process groups or + * job control, so there it is the processes the shell started since the command was typed, each + * ended with its whole tree. A launcher can stand between the pty and the real shell (Git for + * Windows' `bin\bash.exe` starts `usr\bin\bash.exe`), so the shell is followed down that chain + * first. Every other process from before the command (a server the user started in the background + * earlier, and anything that server starts later) is someone else's and is left running. Creation + * times and the command's start come from the same system clock, and the shell only starts a + * program after it reads the command, so no tolerance is needed there. */ import { spawn } from 'node:child_process' +import { win32 } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { windowsPowerShellPath, windowsSystem32 } from '@/main/terminal/shell-path' const logger = createLogger('DesktopTerminalProcessGroup') const LOOKUP_TIMEOUT_MS = 2_000 +/** Windows PowerShell takes a second or more to start on a cold machine. */ +const WINDOWS_LOOKUP_TIMEOUT_MS = 5_000 + +/** Reads and signals what holds a terminal's foreground. */ +export interface TerminalProcessGroups { + /** + * Ids of what runs the foreground command, which was typed into the shell at `startedAt` (epoch + * ms): process group ids on macOS and Linux, process ids on Windows. Empty when the shell itself + * holds the foreground (it is sitting at its prompt, or has exited); null when the OS could not + * be asked, so anything may be running. `shellReadyAt` is when the shell began running its + * startup file, or null when it never announced it: anything it started before then is the + * shell itself. + */ + foreground( + shellPid: number, + startedAt: number, + shellReadyAt: number | null + ): Promise + /** Those of `ids`, from an earlier {@link foreground}, that still run the foreground command. */ + stillRunning(shellPid: number, ids: number[]): Promise + /** Sends `signal` to each id {@link foreground} returned. */ + signal(ids: number[], signal: NodeJS.Signals): void + /** The signals a command that ignored Ctrl-C is sent, in order, until one ends it. */ + escalations: readonly NodeJS.Signals[] + /** + * Whether a command's ids are read before it is sent Ctrl-C rather than once Ctrl-C has failed. + * The tty's foreground is cheap to read and names the command only while it runs: once Ctrl-C + * lands, the shell's own prompt work (a `git status` for the prompt) can take the tty in its + * own group. A Windows lookup starts PowerShell and is scoped by creation time instead, so it + * waits until an escalation is actually needed. + */ + readBeforeInterrupt: boolean +} + +const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { + foreground: readForegroundProcessGroup, + stillRunning: async (shellPid, pgids) => { + const foreground = (await readForegroundProcessGroup(shellPid)) ?? [] + return pgids.filter((pgid) => foreground.includes(pgid)) + }, + signal: (pgids, signal) => { + for (const pgid of pgids) signalProcessGroup(pgid, signal) + }, + escalations: ['SIGTERM', 'SIGKILL'], + readBeforeInterrupt: true, +} + +/** A Windows process, as the process table reports it. */ +export interface WindowsProcess { + pid: number + parentPid: number + /** Epoch milliseconds. */ + createdAt: number +} + /** - * Who holds a terminal's foreground: the shell at its prompt (or a shell that has already - * exited), another process group, or `unknown` when the OS could not be asked. + * The processes a command typed at `startedAt` started, each standing for its whole tree: the + * children created since then of the shell and of the real shell behind any launcher. A launcher's + * real shell is its first child, created before the shell began running its startup file at + * `shellReadyAt`, when no command can have run yet. Other older children are earlier background + * work, so nothing beneath them is the command's, even a worker they start while it runs. */ -export type ForegroundProcessGroup = - | { kind: 'shell' } - | { kind: 'group'; pgid: number } - | { kind: 'unknown' } - -const SHELL: ForegroundProcessGroup = { kind: 'shell' } -const UNKNOWN: ForegroundProcessGroup = { kind: 'unknown' } +function commandRoots( + table: WindowsProcess[], + shellPid: number, + startedAt: number, + shellReadyAt: number | null +): number[] { + const byPid = new Map(table.map((entry) => [entry.pid, entry])) + const children = new Map() + for (const entry of table) { + if (entry.pid === entry.parentPid) continue + children.set(entry.parentPid, [...(children.get(entry.parentPid) ?? []), entry]) + } + const roots: number[] = [] + const visited = new Set() + for (let shell = byPid.get(shellPid); shell && !visited.has(shell.pid); ) { + visited.add(shell.pid) + const own = children.get(shell.pid) ?? [] + for (const child of own) if (child.createdAt >= startedAt) roots.push(child.pid) + const first = own.reduce( + (oldest, child) => (!oldest || child.createdAt < oldest.createdAt ? child : oldest), + undefined + ) + const launched = first && shellReadyAt !== null && first.createdAt <= shellReadyAt + shell = launched ? first : undefined + } + return roots +} /** - * The tty's foreground process group, read from `ps`. A pid `ps` no longer knows is a shell - * that has exited, so nothing can be running in it. Windows has no `ps` or tty process groups, - * so a Windows shell always reads as holding its own foreground. + * Windows, given how to read the process table. The one escalation is a forced tree kill: a + * Windows console program cannot be asked to terminate, and Ctrl-C, the one request it can trap, + * has already been sent. */ -export function readForegroundProcessGroup(shellPid: number): Promise { - if (process.platform === 'win32') return Promise.resolve(SHELL) - if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(UNKNOWN) +export function windowsProcessGroups( + readProcesses: () => Promise +): TerminalProcessGroups { + return { + foreground: async (shellPid, startedAt, shellReadyAt) => { + const table = await readProcesses() + return table && commandRoots(table, shellPid, startedAt, shellReadyAt) + }, + stillRunning: async (_shellPid, pids) => pids.filter(isRunning), + signal: (pids) => { + for (const pid of pids) killProcessTree(pid) + }, + escalations: ['SIGTERM'], + readBeforeInterrupt: false, + } +} + +/** This operating system's way of reading and stopping a terminal's foreground command. */ +export const osProcessGroups: TerminalProcessGroups = + process.platform === 'win32' ? windowsProcessGroups(readProcessTable) : POSIX_PROCESS_GROUPS + +/** The tty's foreground process group, read from `ps`, as {@link TerminalProcessGroups.foreground}. */ +async function readForegroundProcessGroup(shellPid: number): Promise { + if (!isProcessId(shellPid)) return null + const result = await readOutput('ps', ['-o', 'tpgid=', '-p', String(shellPid)], LOOKUP_TIMEOUT_MS) + if (!result) return null + const output = result.stdout.trim() + // `ps` matching nothing is a shell that has exited, so nothing can be running in it; the pid is + // confirmed gone so that a probe failing some other way stays unknown. + if (result.code === 1 && !output) return isRunning(shellPid) ? null : [] + if (result.code !== 0 || !output) return null + const pgid = Number.parseInt(output, 10) + if (!Number.isInteger(pgid)) return null + return pgid <= 0 || pgid === shellPid ? [] : [pgid] +} + +function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void { + try { + process.kill(-pgid, signal) + } catch (error) { + logger.warn('Could not signal a terminal process group', { + signal, + error: getErrorMessage(error), + }) + } +} + +async function readProcessTable(): Promise { + const result = await readOutput( + windowsPowerShellPath(process.env), + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance -ClassName Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $(([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds())" }', + ], + WINDOWS_LOOKUP_TIMEOUT_MS + ) + if (result?.code !== 0) return null + return result.stdout.split(/\r?\n/).flatMap((line) => { + const [pid, parentPid, createdAt] = line.trim().split(' ').map(Number) + return isProcessId(pid) && Number.isInteger(parentPid) && Number.isFinite(createdAt) + ? [{ pid, parentPid, createdAt }] + : [] + }) +} + +function killProcessTree(pid: number): void { + const taskkill = win32.join(windowsSystem32(process.env), 'taskkill.exe') + const child = spawn(taskkill, ['/T', '/F', '/PID', String(pid)], { + stdio: 'ignore', + windowsHide: true, + }) + child.on('error', (error) => { + logger.warn('Could not end a terminal process tree', { error: getErrorMessage(error) }) + }) +} + +/** Whether `pid` still names a process; signal 0 tests for one without sending anything. */ +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +function isProcessId(value: number): boolean { + return Number.isInteger(value) && value > 0 +} + +interface CommandOutput { + code: number | null + stdout: string +} + +/** A short-lived command's exit code and stdout, or null when it could not run or outlived `timeoutMs`. */ +function readOutput( + command: string, + args: string[], + timeoutMs: number +): Promise { return new Promise((resolve) => { let child: ReturnType try { - child = spawn('ps', ['-o', 'tpgid=', '-p', String(shellPid)], { - stdio: ['ignore', 'pipe', 'ignore'], - }) + child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) } catch { - resolve(UNKNOWN) + resolve(null) return } let stdout = '' let settled = false - const finish = (value: ForegroundProcessGroup) => { + const finish = (value: CommandOutput | null) => { if (settled) return settled = true clearTimeout(timer) @@ -50,34 +238,12 @@ export function readForegroundProcessGroup(shellPid: number): Promise { child.kill('SIGKILL') - finish(UNKNOWN) - }, LOOKUP_TIMEOUT_MS) + finish(null) + }, timeoutMs) child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) - child.on('error', () => finish(UNKNOWN)) - child.on('close', (code) => { - const output = stdout.trim() - if (code !== 0 || !output) { - finish(code === 1 && !output ? SHELL : UNKNOWN) - return - } - const pgid = Number.parseInt(output, 10) - if (!Number.isInteger(pgid)) finish(UNKNOWN) - else if (pgid <= 0 || pgid === shellPid) finish(SHELL) - else finish({ kind: 'group', pgid }) - }) + child.on('error', () => finish(null)) + child.on('close', (code) => finish({ code, stdout })) }) } - -/** Sends `signal` to every process in one group. */ -export function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void { - try { - process.kill(-pgid, signal) - } catch (error) { - logger.warn('Could not signal a terminal process group', { - signal, - error: getErrorMessage(error), - }) - } -} diff --git a/apps/desktop/src/main/terminal/pty.ts b/apps/desktop/src/main/terminal/pty.ts new file mode 100644 index 00000000000..19ae981497e --- /dev/null +++ b/apps/desktop/src/main/terminal/pty.ts @@ -0,0 +1,99 @@ +/** + * The pseudo-terminal a shell runs in, with node-pty's platform differences kept here so the + * session above it reads the same everywhere. + * + * On Windows the shell runs on the bundled ConPTY: the one built into Windows can deliver an OSC + * marker ahead of the text written before it, so a command's output would land after its finish + * marker. That build asks for the terminal's device attributes as it starts and holds the shell's + * first output until it hears back, three seconds when nothing answers, so the pty answers that + * one query itself, as xterm.js would, and keeps it from the output so the reply is never sent + * twice; an agent's shell has no panel to answer it at all. + */ +import { spawn } from '@lydell/node-pty' + +const DEVICE_ATTRIBUTES_QUERY = '\u001b[c' +const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' + +/** How much of `data`'s end could be the start of the query, completed by the next chunk. */ +function trailingQueryPrefixLength(data: string): number { + for ( + let length = Math.min(DEVICE_ATTRIBUTES_QUERY.length - 1, data.length); + length > 0; + length-- + ) { + if (DEVICE_ATTRIBUTES_QUERY.startsWith(data.slice(-length))) return length + } + return 0 +} + +interface TerminalPtyOptions { + platform: NodeJS.Platform + shellPath: string + /** The shell's arguments; omitted, it starts as a login shell where the platform has one. */ + args?: string[] + cols: number + rows: number + cwd: string + env: NodeJS.ProcessEnv +} + +/** A running shell's pseudo-terminal. */ +export interface TerminalPty { + readonly pid: number + /** Output as the shell wrote it, less the startup query the pty answered. Register once. */ + onData(handler: (data: string) => void): void + onExit(handler: () => void): void + write(data: string): void + resize(cols: number, rows: number): void + pause(): void + resume(): void + /** Ends the shell. */ + kill(): void +} + +/** Spawns `options.shellPath` in a pseudo-terminal, set up for the platform it runs on. */ +export function spawnTerminalPty(options: TerminalPtyOptions): TerminalPty { + const windows = options.platform === 'win32' + const pty = spawn(options.shellPath, options.args ?? (windows ? [] : ['-l']), { + name: 'xterm-256color', + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: options.env, + ...(windows ? { useConptyDll: true } : {}), + }) + + let startupQueryPending = windows + /** A chunk's tail that may be the query split across chunks, held until the next one. */ + let heldTail = '' + const answerStartupQuery = (chunk: string): string => { + const data = heldTail + chunk + heldTail = '' + const index = data.indexOf(DEVICE_ATTRIBUTES_QUERY) + if (index === -1) { + const held = trailingQueryPrefixLength(data) + heldTail = data.slice(data.length - held) + return data.slice(0, data.length - held) + } + startupQueryPending = false + pty.write(DEVICE_ATTRIBUTES_REPLY) + return data.slice(0, index) + data.slice(index + DEVICE_ATTRIBUTES_QUERY.length) + } + + return { + get pid() { + return pty.pid + }, + onData: (handler) => { + pty.onData((chunk) => handler(startupQueryPending ? answerStartupQuery(chunk) : chunk)) + }, + onExit: (handler) => { + pty.onExit(() => handler()) + }, + write: (data) => pty.write(data), + resize: (cols, rows) => pty.resize(cols, rows), + pause: () => pty.pause(), + resume: () => pty.resume(), + kill: () => pty.kill(), + } +} diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts index 8686b023c63..fc76cb62021 100644 --- a/apps/desktop/src/main/terminal/registry.ts +++ b/apps/desktop/src/main/terminal/registry.ts @@ -313,12 +313,12 @@ export class TerminalRegistry { terminalId ) }, - async (running) => { + async (running, shell) => { if (!ownerWindow || ownerWindow.isDestroyed()) return false const { response } = await showShellDialog(ownerWindow, { type: 'warning', title: 'Close running terminal?', - message: `${describeRunningCommand(running)} is still running.`, + message: `${describeRunningCommand(running, shell)} is still running.`, detail: 'Closing this terminal will stop the process.', buttons: ['Close Terminal', 'Cancel'], primaryVariant: 'destructive', diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index 32a77f6c7a8..eda9cf645b3 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { sleep } from '@sim/utils/helpers' import { describe, expect, it, vi } from 'vitest' import { TerminalService } from '@/main/terminal' -import type { ForegroundProcessGroup } from '@/main/terminal/process-group' import { createRunLedger } from '@/main/terminal/run-ledger' /** @@ -95,6 +94,9 @@ vi.mock('@/main/terminal/tmux', async () => { } }) +/** When a stub session's agent command was typed, as the session reports it. */ +const AGENT_COMMAND_STARTED_AT = vi.hoisted(() => 5_000) + /** Stub sessions by terminal id, populated by the mock below. */ const { stubSessions } = vi.hoisted(() => ({ stubSessions: new Map< @@ -112,7 +114,7 @@ const { stubSessions } = vi.hoisted(() => ({ finishRun(exitCode: number): void /** Returns the run as still going, its command left running, as a wait that ran out does. */ handBack(): void - kill: ReturnType + interrupt: ReturnType readonly runningToolCallId: string | null } >(), @@ -185,10 +187,21 @@ vi.mock('@/main/terminal/session', async () => { get agentCommandToolCallId() { return state.toolCallId }, - kill: vi.fn((signal: string) => { - if (signal === 'SIGINT' && state.interruptible) finishRun(130) + get agentCommandStartedAt() { + return state.toolCallId === null ? null : AGENT_COMMAND_STARTED_AT + }, + get foregroundStartedAt() { + return state.toolCallId === null ? null : AGENT_COMMAND_STARTED_AT + }, + interrupt: vi.fn(() => { + if (state.interruptible) finishRun(130) }), waitForShellIntegration: async () => {}, + /** A busy shell draws no prompt, so the wait lasts until the call is stopped. */ + waitForPromptDrawn: (signal?: AbortSignal) => + state.busy + ? new Promise((resolve) => signal?.addEventListener('abort', () => resolve())) + : Promise.resolve(), /** Stands in for the user running `exit` or pressing Ctrl-D. */ exit: () => { state.disposed = true @@ -198,6 +211,8 @@ vi.mock('@/main/terminal/session', async () => { cols, rows, pid: nextPid++, + spawnedAt: 0, + shellReadyAt: null, env: {}, get alive() { return !state.disposed @@ -206,6 +221,7 @@ vi.mock('@/main/terminal/session', async () => { return state.cwd }, shell: 'zsh', + shellProgram: 'zsh', get foreground() { return state.busy ? 'sleep 1' : null }, @@ -341,7 +357,7 @@ describe('focus-gated shortcuts', () => { terminal.handleFocusedShortcut('close-tab', renderer.window, vi.fn(), confirmClose) ).toBe(true) - expect(confirmClose).toHaveBeenCalledWith('sleep 1') + expect(confirmClose).toHaveBeenCalledWith('sleep 1', 'zsh') expect(terminal.getTabs().activeTerminalId).toBe(activeId) }) @@ -413,6 +429,26 @@ describe('focus-gated shortcuts', () => { }) }) +describe('running into a busy terminal', () => { + it('refuses with BUSY at once rather than first waiting for a prompt', async () => { + const terminal = service() + const id = terminal.start({ cols: 80, rows: 24 }).activeTerminalId as string + stubSessions.get(id)?.setBusy(true) + try { + let response: unknown + void terminal + .executeTool('call-busy', 'run', { terminalId: id, command: 'ls' }) + .then((result) => { + response = result + }) + + await vi.waitFor(() => expect(response).toMatchObject({ ok: false, code: 'BUSY' })) + } finally { + terminal.dispose() + } + }) +}) + describe('handing the terminal to the user', () => { it('resolves when the blocked command finishes, without the user pressing anything', async () => { // Answering the prompt in the panel is the common case: the command @@ -488,10 +524,16 @@ describe('a shell that ends by itself', () => { }) describe('running work before a restart', () => { - function serviceWithForeground(read: (shellPid: number) => Promise) { + function serviceWithForeground(foreground: number[] | null) { const terminal = new TerminalService({ loadCwd: () => '/tmp', - processGroups: { foreground: vi.fn(read), signal: vi.fn() }, + processGroups: { + foreground: vi.fn(async () => foreground), + stillRunning: vi.fn(async () => []), + signal: vi.fn(), + escalations: [], + readBeforeInterrupt: true, + }, }) const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) const session = stubSessions.get(activeTerminalId as string) @@ -500,22 +542,25 @@ describe('running work before a restart', () => { } it('asks the OS about a shell that announces nothing, and trusts one that does', async () => { - const { terminal, session } = serviceWithForeground(async () => ({ kind: 'group', pgid: 4242 })) + const { terminal, session } = serviceWithForeground([4242]) session.setShellIntegration(false) - await expect(terminal.runningWork()).resolves.toEqual({ commands: [''], uncheckedCount: 0 }) + await expect(terminal.runningWork()).resolves.toEqual({ + commands: [{ command: '', shell: 'zsh' }], + uncheckedCount: 0, + }) session.setShellIntegration(true) await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 0 }) }) it('counts a shell at its own prompt as idle', async () => { - const { terminal, session } = serviceWithForeground(async () => ({ kind: 'shell' })) + const { terminal, session } = serviceWithForeground([]) session.setShellIntegration(false) await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 0 }) }) it('reports a terminal whose foreground could not be read as unchecked, never idle', async () => { - const { terminal, session } = serviceWithForeground(async () => ({ kind: 'unknown' })) + const { terminal, session } = serviceWithForeground(null) session.setShellIntegration(false) await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 1 }) }) @@ -524,15 +569,20 @@ describe('running work before a restart', () => { describe('stopping a tool call', () => { const COMMAND_GROUP = 4242 - function cancellableService() { - /** The tty's foreground group as the OS would report it at each read. */ - let foreground: number | null = COMMAND_GROUP + /** A service on macOS and Linux, unless `platform` says Windows (where lookups wait for need). */ + function cancellableService(platform: 'posix' | 'windows' = 'posix') { + /** What holds the terminal's foreground as the OS would report it at each read. */ + let foreground: number[] | null = [COMMAND_GROUP] const processGroups = { foreground: vi.fn( - async (_shellPid: number): Promise => - foreground === null ? { kind: 'unknown' } : { kind: 'group', pgid: foreground } + async (_shellPid: number, _startedAt: number): Promise => foreground ), - signal: vi.fn((_pgid: number, _signal: NodeJS.Signals) => {}), + stillRunning: vi.fn(async (_shellPid: number, ids: number[]) => + ids.filter((id) => foreground?.includes(id)) + ), + signal: vi.fn((_ids: number[], _signal: NodeJS.Signals) => {}), + escalations: ['SIGTERM', 'SIGKILL'] as NodeJS.Signals[], + readBeforeInterrupt: platform === 'posix', } const terminal = new TerminalService({ loadCwd: () => '/tmp', processGroups }) const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) @@ -542,8 +592,8 @@ describe('stopping a tool call', () => { terminal, session, processGroups, - setForeground: (pgid: number | null) => { - foreground = pgid + setForeground: (ids: number[] | null) => { + foreground = ids }, } } @@ -560,13 +610,14 @@ describe('stopping a tool call', () => { } it('interrupts the command its run started, and the run reports how it ended', async () => { - const { terminal, session, processGroups } = cancellableService() + const { terminal, session, processGroups } = cancellableService('windows') const { running } = await startRun(terminal, session, 'call-run') await expect(terminal.cancelTool('call-run')).resolves.toBe(true) - expect(session.kill).toHaveBeenCalledWith('SIGINT') + expect(session.interrupt).toHaveBeenCalled() await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 130 } }) + expect(processGroups.foreground).not.toHaveBeenCalled() expect(processGroups.signal).not.toHaveBeenCalled() }) @@ -578,7 +629,7 @@ describe('stopping a tool call', () => { await terminal.cancelTool('call-stubborn') - expect(processGroups.signal).toHaveBeenCalledWith(COMMAND_GROUP, 'SIGTERM') + expect(processGroups.signal).toHaveBeenCalledWith([COMMAND_GROUP], 'SIGTERM') await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 143 } }) }) @@ -590,25 +641,121 @@ describe('stopping a tool call', () => { await terminal.cancelTool('call-unreadable') - expect(session.kill).toHaveBeenCalledWith('SIGINT') + expect(session.interrupt).toHaveBeenCalled() expect(processGroups.signal).not.toHaveBeenCalled() session.finishRun(0) await running }) + it('signals only the processes of the stopped command that are still running', async () => { + const { terminal, session, processGroups, setForeground } = cancellableService() + session.setInterruptible(false) + setForeground([101, 102]) + // One of a Windows pipeline's processes exits after the first escalation. + processGroups.signal.mockImplementationOnce(() => setForeground([102, 103])) + processGroups.signal.mockImplementationOnce(() => session.finishRun(1)) + const { running } = await startRun(terminal, session, 'call-pipeline') + + await terminal.cancelTool('call-pipeline') + + expect(processGroups.signal).toHaveBeenNthCalledWith(1, [101, 102], 'SIGTERM') + expect(processGroups.signal).toHaveBeenNthCalledWith(2, [102], 'SIGKILL') + expect(processGroups.foreground).toHaveBeenCalledTimes(1) + await running + }) + it('never signals a different command that took the foreground meanwhile', async () => { const { terminal, session, processGroups, setForeground } = cancellableService() session.setInterruptible(false) + // The shell's prompt work (a `git status` for the prompt) takes the tty once Ctrl-C lands. + session.interrupt.mockImplementation(() => setForeground([9999])) const { running } = await startRun(terminal, session, 'call-replaced') - session.kill.mockImplementation(() => setForeground(9999)) await terminal.cancelTool('call-replaced') + expect(session.interrupt).toHaveBeenCalled() expect(processGroups.signal).not.toHaveBeenCalled() session.finishRun(0) await running }) + it('stops escalating once a different command takes the foreground', async () => { + const { terminal, session, processGroups, setForeground } = cancellableService() + session.setInterruptible(false) + processGroups.signal.mockImplementation(() => setForeground([9999])) + const { running } = await startRun(terminal, session, 'call-taken-over') + + await terminal.cancelTool('call-taken-over') + + expect(processGroups.signal).toHaveBeenCalledTimes(1) + expect(processGroups.signal).toHaveBeenCalledWith([COMMAND_GROUP], 'SIGTERM') + session.finishRun(0) + await running + }) + + it('signals nothing when the command ends while its processes are read', async () => { + const { terminal, session, processGroups } = cancellableService() + session.setInterruptible(false) + processGroups.foreground.mockImplementation(async () => { + session.finishRun(0) + return [COMMAND_GROUP] + }) + const { running } = await startRun(terminal, session, 'call-ended') + + await terminal.cancelTool('call-ended') + + expect(processGroups.signal).not.toHaveBeenCalled() + await running + }) + + it('stops escalating after the last signal the platform has', async () => { + const { terminal, session, processGroups } = cancellableService() + session.setInterruptible(false) + const { running } = await startRun(terminal, session, 'call-windows') + processGroups.escalations = ['SIGTERM'] + + await terminal.cancelTool('call-windows') + + expect(processGroups.signal).toHaveBeenCalledTimes(1) + expect(processGroups.signal).toHaveBeenCalledWith([COMMAND_GROUP], 'SIGTERM') + session.finishRun(0) + await running + }) + + it("ends a terminating kill's command, never the shell, on every platform", async () => { + for (const platform of ['posix', 'windows'] as const) { + const { terminal, session, processGroups } = cancellableService(platform) + session.setInterruptible(false) + const { running } = await startRun(terminal, session, `call-${platform}`) + + await expect( + terminal.executeTool(`kill-${platform}`, 'kill', { signal: 'SIGTERM' }) + ).resolves.toMatchObject({ ok: true, result: { signal: 'SIGTERM', signalled: true } }) + + expect(processGroups.foreground).toHaveBeenCalledWith( + session.pid, + AGENT_COMMAND_STARTED_AT, + null + ) + expect(processGroups.signal).toHaveBeenCalledWith([COMMAND_GROUP], 'SIGTERM') + expect(session.interrupt).not.toHaveBeenCalled() + expect(terminal.getTabs().tabs).toHaveLength(1) + session.finishRun(143) + await running + } + }) + + it('signals nothing for a terminating kill when only the shell holds the foreground', async () => { + const { terminal, session, processGroups, setForeground } = cancellableService() + session.setBusy(true) + setForeground([]) + + await expect( + terminal.executeTool('kill-idle', 'kill', { signal: 'SIGKILL' }) + ).resolves.toMatchObject({ ok: true, result: { signalled: false } }) + expect(processGroups.signal).not.toHaveBeenCalled() + }) + it('runs nothing when the Stop arrives before the command starts', async () => { const { terminal, session } = cancellableService() const running = terminal.executeTool('call-early', 'run', { command: 'rm -rf build' }) @@ -627,7 +774,7 @@ describe('stopping a tool call', () => { await expect(killing).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) await expect(typing).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) - expect(session.kill).not.toHaveBeenCalled() + expect(session.interrupt).not.toHaveBeenCalled() }) it("stops the agent's running command at sign-out", async () => { @@ -636,10 +783,24 @@ describe('stopping a tool call', () => { await terminal.stopAgentCommands() - expect(session.kill).toHaveBeenCalledWith('SIGINT') + expect(session.interrupt).toHaveBeenCalled() await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 130 } }) }) + it('never signals what took the foreground after Ctrl-C at sign-out', async () => { + const { terminal, session, processGroups, setForeground } = cancellableService() + session.setInterruptible(false) + session.interrupt.mockImplementation(() => setForeground([9999])) + const { running } = await startRun(terminal, session, 'call-sign-out') + + await terminal.stopAgentCommands() + + expect(session.interrupt).toHaveBeenCalled() + expect(processGroups.signal).not.toHaveBeenCalled() + session.finishRun(0) + await running + }) + it('stops the command a run handed back as still going, once asked for that call', async () => { const { terminal, session } = cancellableService() const { running } = await startRun(terminal, session, 'call-handed-back') @@ -649,10 +810,10 @@ describe('stopping a tool call', () => { await expect(terminal.cancelTool('call-handed-back')).resolves.toBe(false) await terminal.stopAgentCommand('call-other') - expect(session.kill).not.toHaveBeenCalled() + expect(session.interrupt).not.toHaveBeenCalled() await terminal.stopAgentCommand('call-handed-back') - expect(session.kill).toHaveBeenCalledWith('SIGINT') + expect(session.interrupt).toHaveBeenCalled() expect(session.runningToolCallId).toBeNull() }) @@ -662,7 +823,7 @@ describe('stopping a tool call', () => { await terminal.stopAgentCommands() - expect(session.kill).not.toHaveBeenCalled() + expect(session.interrupt).not.toHaveBeenCalled() expect(processGroups.signal).not.toHaveBeenCalled() }) @@ -671,7 +832,7 @@ describe('stopping a tool call', () => { const { running } = await startRun(terminal, session, 'call-other') await expect(terminal.cancelTool('call-unknown')).resolves.toBe(false) - expect(session.kill).not.toHaveBeenCalled() + expect(session.interrupt).not.toHaveBeenCalled() expect(processGroups.signal).not.toHaveBeenCalled() session.finishRun(0) await running diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index ce2725af3ae..bb133138274 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -11,23 +11,27 @@ const ptyStub = vi.hoisted(() => ({ dataHandler: null as ((data: string) => void) | null, exitHandler: null as (() => void) | null, writes: [] as string[], + args: [] as string[], })) vi.mock('@lydell/node-pty', () => ({ - spawn: () => ({ - pid: 1234, - onData: (handler: (data: string) => void) => { - ptyStub.dataHandler = handler - }, - onExit: (handler: () => void) => { - ptyStub.exitHandler = handler - }, - write: (data: string) => ptyStub.writes.push(data), - resize: vi.fn(), - kill: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - }), + spawn: (_file: string, args: string[]) => { + ptyStub.args = args + return { + pid: 1234, + onData: (handler: (data: string) => void) => { + ptyStub.dataHandler = handler + }, + onExit: (handler: () => void) => { + ptyStub.exitHandler = handler + }, + write: (data: string) => ptyStub.writes.push(data), + resize: vi.fn(), + kill: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + } + }, })) vi.mock('@/main/terminal/shell-integration', async (importOriginal) => { @@ -165,6 +169,94 @@ describe('TerminalSession command lifecycle', () => { } }) + it('returns the output that arrives in one chunk with its start and finish markers', async () => { + const session = TerminalSession.create({ + terminalId: 'terminal-batched', + cwd: '/tmp', + cols: 80, + rows: 24, + callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + try { + const result = session.runCommand('echo sim-ok', 'tool-call-batched', 60_000) + // A fast command on a ConPTY: the echoed line, both markers, the output, and the next prompt + // flushed together. + ptyStub.dataHandler?.( + 'echo sim-ok\r\n\u001b]633;C;test-nonce\u0007sim-ok\r\n\u001b]633;D;0;test-nonce\u0007\u001b]633;A;test-nonce\u0007$ ' + ) + + await expect(result).resolves.toMatchObject({ + status: 'completed', + exitCode: 0, + output: 'sim-ok', + }) + } finally { + session.dispose() + } + }) + + describe('handing back a command that looks stuck on a prompt', () => { + function promptSession(): TerminalSession { + return TerminalSession.create({ + terminalId: 'terminal-prompt', + cwd: '/tmp', + cols: 80, + rows: 24, + callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + } + + /** Whether `promise` has settled after `ms` of quiet. */ + async function settledAfter(promise: Promise, ms: number): Promise { + let settled = false + void promise.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(ms) + return settled + } + + it('keeps waiting on a silent command whose only output is escape codes', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + const result = session.runCommand('sleep 60', 'tool-call-silent', 60_000) + // ConPTY repaints the cursor and resets colour after the command line is echoed. + ptyStub.dataHandler?.('\u001b]633;C;test-nonce\u0007\u001b[?25h\u001b[m') + + expect(await settledAfter(result, 5_000)).toBe(false) + ptyStub.dataHandler?.('\u001b]633;D;0;test-nonce\u0007') + await expect(result).resolves.toMatchObject({ status: 'completed' }) + } finally { + session.dispose() + } + }) + + it.each([ + ['Stop', (session: TerminalSession) => session.interrupt()], + ['the agent', (session: TerminalSession) => session.sendKey('ctrl-c')], + ['the user typing in the panel', (session: TerminalSession) => session.write('\u0003')], + ])( + "never reads an interrupted command's ^C echo as a prompt, sent by %s", + async (_by, interrupt) => { + vi.useFakeTimers() + const session = promptSession() + try { + const result = session.runCommand('stubborn', 'tool-call-stop', 60_000) + ptyStub.dataHandler?.('\u001b]633;C;test-nonce\u0007working\r\n') + interrupt(session) + ptyStub.dataHandler?.('^C') + + expect(await settledAfter(result, 5_000)).toBe(false) + ptyStub.dataHandler?.('\u001b]633;D;143;test-nonce\u0007') + await expect(result).resolves.toMatchObject({ status: 'completed', exitCode: 143 }) + } finally { + session.dispose() + } + } + ) + }) + it('stops pressing a batch of keys once the call is stopped', async () => { vi.useFakeTimers() const session = TerminalSession.create({ @@ -221,3 +313,176 @@ describe('TerminalSession command lifecycle', () => { } }) }) + +describe('waiting for the prompt before typing a command', () => { + const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007` + + function promptSession(): TerminalSession { + return TerminalSession.create({ + terminalId: 'terminal-prompt-drawn', + cwd: '/tmp', + cols: 80, + rows: 24, + callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + } + + async function settledAfter(promise: Promise, ms: number): Promise { + let settled = false + void promise.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(ms) + return settled + } + + it('waits, in a shell that reports it, until the next prompt is drawn', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + ptyStub.dataHandler?.(`${marker('A')}$ ${marker('B')}`) + // The last command just finished: its prompt is not drawn yet. + ptyStub.dataHandler?.(`${marker('D;0')}${marker('A')}`) + const drawn = session.waitForPromptDrawn() + + expect(await settledAfter(drawn, 500)).toBe(false) + ptyStub.dataHandler?.(`$ ${marker('B')}`) + expect(await settledAfter(drawn, 0)).toBe(true) + } finally { + session.dispose() + } + }) + + it('never waits on a shell that does not report its prompt', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + ptyStub.dataHandler?.(`${marker('D;0')}${marker('A')}`) + expect(await settledAfter(session.waitForPromptDrawn(), 0)).toBe(true) + } finally { + session.dispose() + } + }) + + it('types anyway once a prompt that reports itself is slow to come', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + ptyStub.dataHandler?.(`${marker('A')}$ ${marker('B')}${marker('A')}`) + expect(await settledAfter(session.waitForPromptDrawn(), 2_100)).toBe(true) + } finally { + session.dispose() + } + }) +}) + +describe('TerminalSession on Windows', () => { + const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007` + + function sessionOn( + platform: NodeJS.Platform, + onData: (terminalId: string, data: string) => void = () => {} + ): TerminalSession { + return TerminalSession.create({ + terminalId: 'terminal-windows', + cwd: 'C:\\Users\\me', + cols: 80, + rows: 24, + platform, + callbacks: { onData, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + } + + const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' + + it("answers ConPTY's startup query once, which otherwise holds the shell back for 3 s", async () => { + vi.useFakeTimers() + let painted = '' + const session = sessionOn('win32', (_id, data) => { + painted += data + }) + try { + ptyStub.dataHandler?.('\u001b[1t') + ptyStub.dataHandler?.('\u001b[c\u001b[?1004h') + // A program's own query later on is the panel's to answer. + ptyStub.dataHandler?.('\u001b[c') + await vi.advanceTimersByTimeAsync(50) + + expect(ptyStub.writes.filter((write) => write === DEVICE_ATTRIBUTES_REPLY)).toHaveLength(1) + expect(painted).toBe('\u001b[1t\u001b[?1004h\u001b[c') + } finally { + session.dispose() + } + }) + + it.each([ + [['\u001b[1t\u001b', '[c\u001b[?1004h']], + [['\u001b[1t\u001b[', 'c\u001b[?1004h']], + [['\u001b[1t\u001b', '[', 'c\u001b[?1004h']], + ])('answers the startup query when ConPTY splits it across chunks: %j', async (chunks) => { + vi.useFakeTimers() + let painted = '' + const session = sessionOn('win32', (_id, data) => { + painted += data + }) + try { + for (const chunk of chunks) ptyStub.dataHandler?.(chunk) + await vi.advanceTimersByTimeAsync(50) + + expect(ptyStub.writes.filter((write) => write === DEVICE_ATTRIBUTES_REPLY)).toHaveLength(1) + expect(painted).toBe('\u001b[1t\u001b[?1004h') + } finally { + session.dispose() + } + }) + + it('releases a held escape the next chunk shows was not the query', async () => { + vi.useFakeTimers() + let painted = '' + const session = sessionOn('win32', (_id, data) => { + painted += data + }) + try { + ptyStub.dataHandler?.('\u001b[1t\u001b[') + ptyStub.dataHandler?.('?1004h') + await vi.advanceTimersByTimeAsync(50) + + expect(painted).toBe('\u001b[1t\u001b[?1004h') + expect(ptyStub.writes).not.toContain(DEVICE_ATTRIBUTES_REPLY) + } finally { + session.dispose() + } + }) + + it('leaves the startup query of a macOS shell to the panel', async () => { + vi.useFakeTimers() + const session = sessionOn('darwin') + try { + ptyStub.dataHandler?.('\u001b[c') + await vi.advanceTimersByTimeAsync(50) + + expect(ptyStub.writes).not.toContain(DEVICE_ATTRIBUTES_REPLY) + } finally { + session.dispose() + } + }) + + it('starts the shell without the POSIX login flag, which Windows shells reject', () => { + const session = sessionOn('win32') + try { + expect(ptyStub.args).not.toContain('-l') + } finally { + session.dispose() + } + }) + + it('names the tab after the last folder of a Windows path', () => { + const session = sessionOn('win32') + try { + ptyStub.dataHandler?.(marker('P;Cwd=D:\\\\work\\\\project')) + expect(session.tabState(true).title).toBe('project') + } finally { + session.dispose() + } + }) +}) diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 60afc3e54e3..69659e836aa 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -6,11 +6,9 @@ * batching, the scrollback ring buffer, and the command lifecycle derived from * shell-integration markers. */ -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { IPty } from '@lydell/node-pty' -import { spawn } from '@lydell/node-pty' import { createLogger } from '@sim/logger' import { MAX_CAPTURE_CHARS, @@ -31,12 +29,16 @@ import { type IBufferLine, } from '@xterm/headless' import { readProcessCwd } from '@/main/terminal/process-cwd' +import { spawnTerminalPty, type TerminalPty } from '@/main/terminal/pty' import { buildShellLaunch, createNonce, detectShell, ShellIntegrationParser, + type ShellMarker, + shellKind, } from '@/main/terminal/shell-integration' +import { resolveShellPath } from '@/main/terminal/shell-path' const logger = createLogger('DesktopTerminalSession') @@ -245,6 +247,9 @@ const KEYSTROKE_GAP_MS = 150 */ const KEYSTROKE_SETTLE_MAX_MS = 1_000 +/** Cap on waiting for a shell's prompt to be drawn before typing into it anyway. */ +const PROMPT_DRAW_MAX_MS = 2_000 + interface PendingCommand { command: string toolCallId: string @@ -257,6 +262,14 @@ interface PendingCommand { /** Rolling tail kept once {@link MAX_CAPTURE_CHARS} is exceeded. */ overflow: string capturing: boolean + /** + * Set once Ctrl-C was sent, by Stop, the agent, or the user. Whatever it leaves on screen (the + * tty's `^C` echo) is the interrupt landing, not a prompt, so the command is waited on to end, or + * for Stop to end it. The trade-off: a program that catches Ctrl-C and goes back to reading input + * (a REPL, a confirmation) is no longer recognised as waiting at a prompt, so the run waits out + * its full wait instead of returning early. + */ + interrupted: boolean timer: NodeJS.Timeout resolve(result: TerminalRunResult): void } @@ -294,16 +307,39 @@ export interface TerminalSessionOptions { cols: number rows: number callbacks: TerminalSessionCallbacks + /** The operating system the shell runs on; the current one by default. */ + platform?: NodeJS.Platform +} + +/** + * The shell, found once for each platform and `SHELL`: on Windows the search stats every PATH + * entry, and which shells are installed does not change while Sim runs. + */ +let resolvedShell: { + platform: NodeJS.Platform + configured: string | undefined + path: string +} | null = null + +function shellPathFor(platform: NodeJS.Platform): string { + const configured = process.env.SHELL + if (resolvedShell?.platform !== platform || resolvedShell.configured !== configured) { + const path = resolveShellPath({ platform, env: process.env, exists: existsSync }) + resolvedShell = { platform, configured, path } + } + return resolvedShell.path } export class TerminalSession { - private readonly pty: IPty + private readonly pty: TerminalPty /** The environment the shell was spawned with, for tmux to reuse. */ private readonly shellEnv: NodeJS.ProcessEnv private readonly parser: ShellIntegrationParser private readonly integrationDir: string private readonly callbacks: TerminalSessionCallbacks private readonly shellName: string + /** The shell's program name (`zsh`, `pwsh`, `cmd`), for reading its command lines. */ + readonly shellProgram: string readonly terminalId: string /** @@ -330,7 +366,8 @@ export class TerminalSession { private shellIntegration = false /** When the shell began the startup files we generated, or null while it has not. */ private startupBegunAt: number | null = null - private readonly spawnedAt = Date.now() + /** When the shell was spawned, in epoch milliseconds; nothing typed into it starts before. */ + readonly spawnedAt = Date.now() private altScreen = false private foregroundCommand: string | null = null private foregroundToolCallId: string | null = null @@ -340,15 +377,26 @@ export class TerminalSession { * the command is still the agent's, so Stop and sign-out can still end it. */ private agentToolCallId: string | null = null + /** When that command was typed into the shell; anything it started began after this. */ + private agentCommandSentAt: number | null = null + /** + * When the shell last began its prompt (`A`), or null before it first does. A command the user + * types is started after that prompt reached the screen, so after this. + */ + private promptStartedAt: number | null = null private pendingCommand: PendingCommand | null = null /** Command line reported by the shell but not yet bracketed by output-start. */ private announcedCommand: string | null = null - /** Pending shell-readiness waits, rechecked whenever the shell's startup moves on. */ + /** Pending readiness and prompt waits, rechecked whenever startup moves on or a prompt is drawn. */ private readonly readinessWaiters = new Set<() => void>() + /** Whether the shell says when its prompt is drawn (`B`); bash and PowerShell do, zsh does not. */ + private reportsPromptEnd = false + /** Whether the prompt is drawn and reading input, as far as the shell has said. */ + private promptDrawn = false private constructor( options: TerminalSessionOptions, - pty: IPty, + pty: TerminalPty, integrationDir: string, nonce: string, shellName: string, @@ -364,6 +412,7 @@ export class TerminalSession { this.shellEnv = shellEnv this.integrationDir = integrationDir this.shellName = shellName + this.shellProgram = shellKind(shellName) this.parser = new ShellIntegrationParser(nonce) this.pty.onData((chunk) => this.handleData(chunk)) @@ -371,7 +420,8 @@ export class TerminalSession { } static create(options: TerminalSessionOptions): TerminalSession { - const shellPath = process.env.SHELL || '/bin/zsh' + const platform = options.platform ?? process.platform + const shellPath = shellPathFor(platform) const shell = detectShell(shellPath) const nonce = createNonce() const integrationDir = mkdtempSync(join(tmpdir(), 'sim-terminal-')) @@ -384,13 +434,13 @@ export class TerminalSession { // A shell we cannot instrument still gives the user a working terminal; // the agent is refused separately via NO_SHELL_INTEGRATION. - const launch = shell - ? buildShellLaunch(shell, integrationDir, nonce, env) - : { args: ['-l'], env: {} } + const launch = shell ? buildShellLaunch(shell, integrationDir, nonce, env) : null - const shellEnv = { ...env, ...launch.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } - const pty = spawn(shellPath, launch.args, { - name: 'xterm-256color', + const shellEnv = { ...env, ...launch?.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } + const pty = spawnTerminalPty({ + platform, + shellPath, + args: launch?.args, cols: options.cols, rows: options.rows, cwd: options.cwd, @@ -478,6 +528,25 @@ export class TerminalSession { return this.agentToolCallId } + /** When the shell began running its startup file, or null before (or for a shell that never says). */ + get shellReadyAt(): number | null { + return this.startupBegunAt + } + + /** When the agent's still-running command was typed into the shell, in epoch milliseconds. */ + get agentCommandStartedAt(): number | null { + return this.agentCommandSentAt + } + + /** + * The earliest the command holding the foreground can have started, in epoch milliseconds: when + * the agent typed it, else when the shell last began its prompt; null for a shell that announces + * neither. + */ + get foregroundStartedAt(): number | null { + return this.agentCommandSentAt ?? this.promptStartedAt + } + /** * Tab-strip view of this terminal. The label prefers the running command, * which is what the user is actually waiting on, and falls back to the @@ -485,7 +554,7 @@ export class TerminalSession { * at a glance. */ tabState(active: boolean): TerminalTabState { - const directory = this.cwd ? (this.cwd.split('/').filter(Boolean).pop() ?? '/') : null + const directory = this.cwd ? (this.cwd.split(/[\\/]/).filter(Boolean).pop() ?? '/') : null return { terminalId: this.terminalId, // The directory, always: whether to show the running command instead is @@ -502,6 +571,7 @@ export class TerminalSession { running: this.foregroundCommand === null ? null : this.foregroundCommand.trim() || 'command', interactive: this.altScreen, active, + shell: this.shellProgram, } } @@ -561,12 +631,38 @@ export class TerminalSession { return now - this.startupBegunAt >= bounds.startingMs ? 'starting' : null } + /** + * Resolves once the prompt is drawn and reading input, for a shell that reports it; at once for + * one that does not. Git Bash drops what is typed while it is still switching from a native + * program back to its prompt, so a command typed straight after the last one finished can vanish. + * Also resolves after {@link PROMPT_DRAW_MAX_MS}, on exit, or when `signal` aborts. + */ + waitForPromptDrawn(signal?: AbortSignal): Promise { + return new Promise((resolve) => { + let timer: NodeJS.Timeout | null = null + const done = () => { + if (timer) clearTimeout(timer) + this.readinessWaiters.delete(check) + signal?.removeEventListener('abort', done) + resolve() + } + const check = () => { + if (this.promptDrawn || !this.reportsPromptEnd || this.disposed) done() + } + timer = setTimeout(done, PROMPT_DRAW_MAX_MS) + this.readinessWaiters.add(check) + signal?.addEventListener('abort', done) + check() + }) + } + private notifyReadinessWaiters(): void { for (const check of [...this.readinessWaiters]) check() } write(data: string): void { if (this.disposed) return + if (this.pendingCommand && data.includes('\u0003')) this.pendingCommand.interrupted = true this.pty.write(data) } @@ -634,19 +730,13 @@ export class TerminalSession { this.emitState() } - kill(signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void { + /** + * Sends Ctrl-C as a keystroke, so the foreground command gets it the way the user's own would, + * rather than only the shell. + */ + interrupt(): void { if (this.disposed) return - // SIGINT is delivered as a keystroke so the foreground process group gets - // it the way Ctrl-C would, rather than only the shell. - if (signal === 'SIGINT') { - this.write('\u0003') - return - } - try { - this.pty.kill(signal) - } catch (error) { - logger.warn('Failed to signal pty', { signal, error: (error as Error).message }) - } + this.write('\u0003') } /** @@ -671,12 +761,14 @@ export class TerminalSession { output: '', overflow: '', capturing: false, + interrupted: false, timer, resolve, } this.foregroundCommand = command this.foregroundToolCallId = toolCallId this.agentToolCallId = toolCallId + this.agentCommandSentAt = Date.now() this.emitState() this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command, toolCallId }) @@ -823,35 +915,36 @@ export class TerminalSession { } } + /** + * Applies the chunk's text and markers in the order the shell wrote them, so output between a + * command's start and finish markers is captured even when all three arrive in one chunk. + */ private handleData(chunk: string): void { - const { text, markers } = this.parser.parse(chunk) - - for (const marker of markers) { - this.applyMarker(marker) + for (const segment of this.parser.parse(chunk)) { + if (segment.kind === 'text') this.handleText(segment.text) + else this.applyMarker(segment) } + } - if (text) { - this.lastOutputAt = Date.now() - this.trackAltScreen(text) - this.scrollback += text - if (this.scrollback.length > MAX_SCROLLBACK_CHARS + TRIM_SLACK_CHARS) { - this.scrollback = this.scrollback.slice(-MAX_SCROLLBACK_CHARS) - } - if (this.pendingCommand?.capturing) { - this.pendingCommand.lastActivityAt = Date.now() - this.captureOutput(this.pendingCommand, text) - if (text.includes(ALT_SCREEN_ENTER)) { - this.resolveInteractiveCommand() - } + private handleText(text: string): void { + this.lastOutputAt = Date.now() + this.trackAltScreen(text) + this.scrollback += text + if (this.scrollback.length > MAX_SCROLLBACK_CHARS + TRIM_SLACK_CHARS) { + this.scrollback = this.scrollback.slice(-MAX_SCROLLBACK_CHARS) + } + if (this.pendingCommand?.capturing) { + this.pendingCommand.lastActivityAt = Date.now() + this.captureOutput(this.pendingCommand, text) + if (text.includes(ALT_SCREEN_ENTER)) { + this.resolveInteractiveCommand() } - this.pendingOutput += text - this.scheduleFlush() } + this.pendingOutput += text + this.scheduleFlush() } - private applyMarker( - marker: ReturnType['markers'][number] - ): void { + private applyMarker(marker: ShellMarker): void { switch (marker.kind) { case 'startup': if (this.startupBegunAt === null) { @@ -859,7 +952,14 @@ export class TerminalSession { this.notifyReadinessWaiters() } break + case 'prompt-end': + this.reportsPromptEnd = true + this.promptDrawn = true + this.notifyReadinessWaiters() + break case 'prompt-start': + this.promptDrawn = false + this.promptStartedAt = Date.now() if (!this.shellIntegration) { this.shellIntegration = true this.emitState() @@ -870,6 +970,7 @@ export class TerminalSession { this.announcedCommand = marker.command break case 'output-start': { + this.promptDrawn = false if (this.pendingCommand) { this.pendingCommand.capturing = true break @@ -952,13 +1053,14 @@ export class TerminalSession { * Hands a command back early when it has stopped mid-line and gone quiet — * the shape of something sitting on a prompt. Output that ends with a * newline, or that is still arriving, is a command doing work and is left to - * run out the full wait window. + * run out the full wait window. Escape sequences are not text: ConPTY ends a + * silent command's capture with cursor and colour codes that print nothing. */ private checkForPrompt(): void { const pending = this.pendingCommand - if (!pending?.capturing) return + if (!pending?.capturing || pending.interrupted) return if (Date.now() - pending.lastActivityAt < PROMPT_IDLE_MS) return - const captured = this.capturedText(pending) + const captured = stripAnsi(this.capturedText(pending)) if (!captured.trim() || /[\r\n]$/.test(captured)) return this.resolveStillRunning(true) } @@ -1062,6 +1164,7 @@ export class TerminalSession { this.foregroundCommand = null this.foregroundToolCallId = null this.agentToolCallId = null + this.agentCommandSentAt = null this.announcedCommand = null this.altScreen = false this.emitState() diff --git a/apps/desktop/src/main/terminal/shell-integration.test.ts b/apps/desktop/src/main/terminal/shell-integration.test.ts index 9c16588b5f0..db6f594e34b 100644 --- a/apps/desktop/src/main/terminal/shell-integration.test.ts +++ b/apps/desktop/src/main/terminal/shell-integration.test.ts @@ -16,17 +16,18 @@ function osc(body: string): string { } describe('ShellIntegrationParser', () => { - it('extracts command lifecycle markers and strips them from the display stream', () => { + it('extracts command lifecycle markers in order around the text between them', () => { const parser = new ShellIntegrationParser(NONCE) - const { text, markers } = parser.parse( - `${osc(`E;npm test;${NONCE}`)}${osc(`C;${NONCE}`)}output here${osc(`D;0;${NONCE}`)}` + const segments = parser.parse( + `${osc(`E;npm test;${NONCE}`)}${osc(`C;${NONCE}`)}output here${osc(`D;0;${NONCE}`)}$ ` ) - expect(text).toBe('output here') - expect(markers).toEqual([ + expect(segments).toEqual([ { kind: 'command-line', command: 'npm test' }, { kind: 'output-start' }, + { kind: 'text', text: 'output here' }, { kind: 'output-end', exitCode: 0 }, + { kind: 'text', text: '$ ' }, ]) }) @@ -34,16 +35,10 @@ describe('ShellIntegrationParser', () => { const parser = new ShellIntegrationParser(NONCE) // A file rendered with `cat` can contain a literal finish sequence. Acting // on it would hand the agent a fabricated exit code, so it must be inert. - const { markers } = parser.parse(`BEFORE${osc('D;0;attacker')}AFTER`) - - expect(markers).toEqual([]) - }) - - it('still removes forged sequences from the display stream', () => { - const parser = new ShellIntegrationParser(NONCE) - const { text } = parser.parse(`BEFORE${osc('D;0;attacker')}AFTER`) - - expect(text).toBe('BEFOREAFTER') + // It is still removed from the display stream. + expect(parser.parse(`BEFORE${osc('D;0;attacker')}AFTER`)).toEqual([ + { kind: 'text', text: 'BEFOREAFTER' }, + ]) }) it('reassembles sequences split across chunks', () => { @@ -51,50 +46,56 @@ describe('ShellIntegrationParser', () => { const full = `hello${osc(`D;7;${NONCE}`)}world` const split = full.length - 8 - const first = parser.parse(full.slice(0, split)) - const second = parser.parse(full.slice(split)) - - expect(first.markers).toEqual([]) - expect(second.markers).toEqual([{ kind: 'output-end', exitCode: 7 }]) - expect(first.text + second.text).toBe('helloworld') + expect(parser.parse(full.slice(0, split))).toEqual([{ kind: 'text', text: 'hello' }]) + expect(parser.parse(full.slice(split))).toEqual([ + { kind: 'output-end', exitCode: 7 }, + { kind: 'text', text: 'world' }, + ]) }) it('unescapes semicolons and newlines in command lines', () => { const parser = new ShellIntegrationParser(NONCE) - const { markers } = parser.parse(osc(`E;echo a\\x3bb\\x0ac;${NONCE}`)) - - expect(markers).toEqual([{ kind: 'command-line', command: 'echo a;b\nc' }]) + expect(parser.parse(osc(`E;echo a\\x3bb\\x0ac;${NONCE}`))).toEqual([ + { kind: 'command-line', command: 'echo a;b\nc' }, + ]) }) it('tracks the working directory', () => { const parser = new ShellIntegrationParser(NONCE) - const { markers } = parser.parse(osc(`P;Cwd=/tmp/some dir;${NONCE}`)) + expect(parser.parse(osc(`P;Cwd=/tmp/some dir;${NONCE}`))).toEqual([ + { kind: 'cwd', cwd: '/tmp/some dir' }, + ]) + }) - expect(markers).toEqual([{ kind: 'cwd', cwd: '/tmp/some dir' }]) + it('reads an escaped backslash as one, so a Windows cwd survives intact', () => { + const parser = new ShellIntegrationParser(NONCE) + // A folder named x3b: its escaped form must not decode the `\x3b` into a `;`. + expect(parser.parse(osc(`P;Cwd=C:\\\\Users\\\\x3b;${NONCE}`))).toEqual([ + { kind: 'cwd', cwd: 'C:\\Users\\x3b' }, + ]) }) it("recognises the startup marker sent before the user's startup files", () => { const parser = new ShellIntegrationParser(NONCE) - const { text, markers } = parser.parse(`${osc(`SimStartup;${NONCE}`)}loading`) - - expect(text).toBe('loading') - expect(markers).toEqual([{ kind: 'startup' }]) + expect(parser.parse(`${osc(`SimStartup;${NONCE}`)}loading`)).toEqual([ + { kind: 'startup' }, + { kind: 'text', text: 'loading' }, + ]) }) it('accepts ST as well as BEL as a terminator', () => { const parser = new ShellIntegrationParser(NONCE) - const { text, markers } = parser.parse(`a\u001b]633;A;${NONCE}\u001b\\b`) - - expect(text).toBe('ab') - expect(markers).toEqual([{ kind: 'prompt-start' }]) + expect(parser.parse(`a\u001b]633;A;${NONCE}\u001b\\b`)).toEqual([ + { kind: 'text', text: 'a' }, + { kind: 'prompt-start' }, + { kind: 'text', text: 'b' }, + ]) }) it('releases an unterminated sequence rather than buffering without bound', () => { const parser = new ShellIntegrationParser(NONCE) const runaway = `\u001b]633;${'x'.repeat(9000)}` - const { text } = parser.parse(runaway) - - expect(text).toBe(runaway) + expect(parser.parse(runaway)).toEqual([{ kind: 'text', text: runaway }]) }) }) @@ -104,6 +105,15 @@ describe('detectShell', () => { expect(detectShell('/usr/local/bin/bash')).toBe('bash') }) + it('recognises Windows shells by their executable names', () => { + expect(detectShell('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe('pwsh') + expect(detectShell('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\PowerShell.exe')).toBe( + 'powershell' + ) + expect(detectShell('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('bash') + expect(detectShell('C:\\Windows\\System32\\cmd.exe')).toBeNull() + }) + it('returns null for shells without hooks, leaving the terminal uninstrumented', () => { expect(detectShell('/usr/bin/fish')).toBeNull() expect(detectShell('/bin/sh')).toBeNull() @@ -185,3 +195,40 @@ describe('startup marker', () => { expect(output.indexOf(STARTUP)).toBeLessThan(output.indexOf('user-startup')) }) }) + +/** + * The user's PROMPT_COMMAND runs inside ours, in a real bash, with a typed command and `exit` + * fed on stdin. Whatever the user's value, each typed command must be reported exactly once, + * framed by its own markers, and nothing the user's PROMPT_COMMAND runs may read as a command. + */ +describe.skipIf(!existsSync('/bin/bash'))("bash with the user's PROMPT_COMMAND", () => { + const MARKER = new RegExp(`\u001b\\]633;([^\u0007]*);${NONCE}\u0007`, 'g') + + function markersFor(promptCommand: string): string[] { + const home = mkdtempSync(join(tmpdir(), 'sim-shell-home-')) + writeFileSync( + join(home, '.bashrc'), + `PROMPT_COMMAND=$'${promptCommand.replace(/\n/g, '\\n')}'\n` + ) + const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home } + const launch = buildShellLaunch('bash', mkdtempSync(join(tmpdir(), 'sim-bash-')), NONCE, env) + const { stdout } = spawnSync('/bin/bash', launch.args, { + env: { ...env, ...launch.env }, + input: 'echo hi\nexit\n', + encoding: 'utf8', + }) + return [...stdout.matchAll(MARKER)] + .map((match) => match[1]) + .filter((body) => body !== 'SimStartup' && !body.startsWith('P;')) + } + + it.each([ + ['ends in a semicolon', 'history -a; '], + ['ends in a newline', 'true\n'], + ['holds several commands', 'a=1;b=2'], + ['is empty', ''], + ['is a single command', 'true'], + ])('reports only the typed commands when it %s', (_case, promptCommand) => { + expect(markersFor(promptCommand)).toEqual(['A', 'E;echo hi', 'C', 'D;0', 'A', 'E;exit', 'C']) + }) +}) diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index da7349bbb27..094cbaa57d1 100644 --- a/apps/desktop/src/main/terminal/shell-integration.ts +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -17,16 +17,26 @@ */ import { randomBytes } from 'node:crypto' import { mkdirSync, writeFileSync } from 'node:fs' -import { basename, join } from 'node:path' +import { join, sep } from 'node:path' +import { programName } from '@sim/terminal-protocol' + +const SUPPORTED_SHELLS = ['zsh', 'bash', 'pwsh', 'powershell'] as const /** Shells we can install prompt hooks into. */ -export type SupportedShell = 'zsh' | 'bash' +export type SupportedShell = (typeof SUPPORTED_SHELLS)[number] + +/** + * A shell's program name, as it is reported to the agent: no directory (either + * separator), no login dash, no `.exe`. `C:\Windows\...\PowerShell.exe` is + * `powershell`, `-zsh` is `zsh`. + */ +export function shellKind(shellPath: string): string { + return programName(shellPath).replace(/^-/, '').toLowerCase() +} export function detectShell(shellPath: string): SupportedShell | null { - const name = basename(shellPath) - if (name === 'zsh' || name === '-zsh') return 'zsh' - if (name === 'bash' || name === '-bash') return 'bash' - return null + const kind = shellKind(shellPath) + return SUPPORTED_SHELLS.find((shell) => shell === kind) ?? null } export function createNonce(): string { @@ -35,7 +45,8 @@ export function createNonce(): string { /** * Marker kinds we act on. `A` (prompt start) doubles as the "integration is - * live" signal; `C`/`D` bracket a command's output; `E` reports the exact + * live" signal; `B` (prompt end) says the prompt is drawn and reading input; + * `C`/`D` bracket a command's output; `E` reports the exact * command line; `P` tracks the working directory across `cd`. `SimStartup` is * ours, not VS Code's: the shell sends it before running the user's startup * files, so a shell that is still starting can be told from one we never @@ -44,21 +55,28 @@ export function createNonce(): string { export type ShellMarker = | { kind: 'startup' } | { kind: 'prompt-start' } + | { kind: 'prompt-end' } | { kind: 'command-line'; command: string } | { kind: 'output-start' } | { kind: 'output-end'; exitCode: number } | { kind: 'cwd'; cwd: string } -export interface ParseResult { - /** Stream with the OSC 633 sequences removed, safe to hand to xterm.js. */ - text: string - markers: ShellMarker[] -} +/** + * One piece of the stream, in the order the shell wrote it: a run of display text with the OSC 633 + * sequences removed, or a marker. Order matters because a pty chunk can carry a command's start + * marker, its output, and its finish marker together; read apart, the output cannot be told from + * the text either side of it. + */ +type ParseSegment = { kind: 'text'; text: string } | ShellMarker -/** Undoes the escaping applied by the shell hooks. */ +/** + * Undoes the escaping applied by the shell hooks: `\\` is one backslash and + * `\xHH` one character. Both are read in one pass, so `C:\x3b`, which the + * hooks send as `C:\\x3b`, comes back unchanged rather than as `C:\;`. + */ function unescapeValue(value: string): string { - return value.replace(/\\x([0-9a-fA-F]{2})/g, (_match, hex: string) => - String.fromCharCode(Number.parseInt(hex, 16)) + return value.replace(/\\(?:\\|x([0-9a-fA-F]{2}))/g, (_match, hex: string | undefined) => + hex === undefined ? '\\' : String.fromCharCode(Number.parseInt(hex, 16)) ) } @@ -79,11 +97,11 @@ export class ShellIntegrationParser { */ private static readonly MAX_PENDING = 8192 - parse(chunk: string): ParseResult { + parse(chunk: string): ParseSegment[] { const buffer = this.pending + chunk this.pending = '' - const markers: ShellMarker[] = [] + const segments: ParseSegment[] = [] let text = '' let index = 0 @@ -110,11 +128,16 @@ export class ShellIntegrationParser { const body = buffer.slice(start + '\u001b]633;'.length, terminator.index) const marker = this.toMarker(body) - if (marker) markers.push(marker) + if (marker) { + if (text) segments.push({ kind: 'text', text }) + text = '' + segments.push(marker) + } index = terminator.index + terminator.length } - return { text, markers } + if (text) segments.push({ kind: 'text', text }) + return segments } private toMarker(body: string): ShellMarker | null { @@ -131,6 +154,8 @@ export class ShellIntegrationParser { return { kind: 'startup' } case 'A': return { kind: 'prompt-start' } + case 'B': + return { kind: 'prompt-end' } case 'C': return { kind: 'output-start' } case 'D': { @@ -215,6 +240,7 @@ __sim_preexec() { __sim_precmd() { local st=$? + __sim_at_prompt=1 # Cwd is reported before the finish marker so a \`cd\` is already visible by # the time the command's result is resolved. builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" @@ -239,38 +265,62 @@ add-zsh-hook precmd __sim_precmd /** * bash has no preexec hook, so command start is detected with a DEBUG trap and * command end from PROMPT_COMMAND. The trap fires once per command in a - * pipeline, hence the in-command latch. + * pipeline, hence the in-command latch, and for every command PROMPT_COMMAND + * runs, hence the at-prompt latch: set by `__sim_precmd` and cleared only by + * `__sim_prompt_end`, so the user's own PROMPT_COMMAND never counts as a command. + * The parts of PROMPT_COMMAND are joined by newlines, which parse after any user + * value, including one ending in `;` (`history -a; `) or a newline. */ function writeBashFile(dir: string, nonce: string): string { const rcPath = join(dir, 'sim-bash-rc.sh') writeFileSync( rcPath, `builtin printf '\\e]633;SimStartup;%s\\a' '${nonce}' +# MSYS and Cygwin (Git Bash) set up PATH, /usr/bin included, in /etc/profile, which only a login +# shell reads; their cwd is a POSIX path (/c/..., /tmp) that only cygpath can map to Windows. +# Starting a process is slow there, so cygpath runs only when the directory changed. +if [ -x /usr/bin/cygpath ]; then + [ -f /etc/profile ] && builtin source /etc/profile + __sim_cwd_of='' + __sim_cwd() { + [ "$PWD" = "$__sim_cwd_of" ] && return + __sim_cwd_of=$PWD + __sim_cwd_value=$(/usr/bin/cygpath -w "$PWD" 2>/dev/null) || __sim_cwd_value=$PWD + } +else + __sim_cwd() { __sim_cwd_value=$PWD; } +fi [ -f "$HOME/.bashrc" ] && builtin source "$HOME/.bashrc" __sim_nonce='${nonce}' __sim_in_cmd='' +__sim_at_prompt=1 +# Sets __sim_escaped rather than printing it, so no subshell is forked to read it. __sim_esc() { - local s=\${1//\\\\/\\\\\\\\} - s=\${s//;/\\\\x3b} - s=\${s//$'\\n'/\\\\x0a} - builtin printf '%s' "$s" + __sim_escaped=\${1//\\\\/\\\\\\\\} + __sim_escaped=\${__sim_escaped//;/\\\\x3b} + __sim_escaped=\${__sim_escaped//$'\\n'/\\\\x0a} } __sim_preexec() { case "$BASH_COMMAND" in __sim_*) return ;; esac + [ -n "$__sim_at_prompt" ] && return [ -n "$__sim_in_cmd" ] && return __sim_in_cmd=1 - builtin printf '\\e]633;E;%s;%s\\a' "$(__sim_esc "$BASH_COMMAND")" "$__sim_nonce" + __sim_esc "$BASH_COMMAND" + builtin printf '\\e]633;E;%s;%s\\a' "$__sim_escaped" "$__sim_nonce" builtin printf '\\e]633;C;%s\\a' "$__sim_nonce" } __sim_precmd() { local st=$? + __sim_at_prompt=1 # Cwd is reported before the finish marker so a \`cd\` is already visible by # the time the command's result is resolved. - builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" + __sim_cwd + __sim_esc "$__sim_cwd_value" + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$__sim_escaped" "$__sim_nonce" if [ -n "$__sim_in_cmd" ]; then builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" fi @@ -279,13 +329,127 @@ __sim_precmd() { return $st } +# The prompt-end marker rides at the end of PS1, so it is drawn by readline itself, once the +# prompt is up and reading. Appended after the user's PROMPT_COMMAND, which may rebuild PS1. +__sim_prompt_end() { + local st=$? marker="\\[\\e]633;B;\${__sim_nonce}\\a\\]" + case "$PS1" in *"$marker") ;; *) PS1="$PS1$marker" ;; esac + __sim_at_prompt='' + return $st +} + +# Set before the trap, so assigning it is not reported as a command. +PROMPT_COMMAND="__sim_precmd +$PROMPT_COMMAND +__sim_prompt_end" trap '__sim_preexec' DEBUG -PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ` ) return rcPath } +/** + * PowerShell (7 and Windows PowerShell 5.1) has no preexec hook either. The + * command line is caught by wrapping PSReadLine's `PSConsoleHostReadLine`, + * which returns each line the user accepts, and the finish by wrapping + * `prompt`. Without PSReadLine there is no line to report, so nothing is + * installed and the agent is refused as for any uninstrumented shell. + * + * The shell starts with `-NoProfile` and this script loads the profiles + * itself, in PowerShell's own order, so the startup marker goes out before any + * of the user's code runs and the hooks wrap the prompt their profile set + * (oh-my-posh, starship) rather than being replaced by it. The script travels + * as `-EncodedCommand` because a `.ps1` file is refused under Windows + * PowerShell's default `Restricted` execution policy; the profiles stay under + * the user's policy exactly as they would without us. + * + * The exit code is 0 when the line succeeded, else `$LASTEXITCODE` when a + * native program the line ran set a failing one, else 1 (a failing cmdlet has + * no code). `$LASTEXITCODE` outlives the line that set it, so it counts as this + * line's only when it changed, or when it is unchanged and the line added no + * error record (a native program failing with the same code again); a failing + * cmdlet after a failing program is 1, not that program's code. Both are only + * read, so the user's own `$LASTEXITCODE` and `$Error` are left as they were. + */ +function powerShellScript(nonce: string): string { + return String.raw`$Global:__SimNonce = '${nonce}' +$Global:__SimInCommand = $false + +function Global:__Sim-Osc([string]$Body) { + "$([char]27)]633;$Body;$Global:__SimNonce$([char]7)" +} + +function Global:__Sim-Escape([string]$Value) { + [regex]::Replace($Value, '[\\;\x00-\x1f]', { + param($m) + if ($m.Value -eq '\') { '\\' } else { '\x{0:x2}' -f [int][char]$m.Value } + }) +} + +[Console]::Write((__Sim-Osc 'SimStartup')) + +foreach ($__simProfile in @($PROFILE.AllUsersAllHosts, $PROFILE.AllUsersCurrentHost, $PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) { + if ($__simProfile -and (Test-Path -LiteralPath $__simProfile -PathType Leaf)) { + try { . $__simProfile } catch { Write-Error -ErrorRecord $_ } + } +} +Remove-Variable -Name __simProfile -ErrorAction SilentlyContinue + +if (-not (Get-Module -Name PSReadLine)) { Import-Module PSReadLine -ErrorAction SilentlyContinue } + +if (Test-Path -Path Function:\PSConsoleHostReadLine) { + # Sim clears a half-typed line with Ctrl+U before typing a command; Windows mode leaves it unbound. + try { + if (-not (Get-PSReadLineKeyHandler -Bound | Where-Object { $_.Key -eq 'Ctrl+u' })) { + Set-PSReadLineKeyHandler -Chord 'Ctrl+u' -Function BackwardDeleteLine + } + } catch {} + + $Global:__SimReadLine = $function:PSConsoleHostReadLine + function Global:PSConsoleHostReadLine { + $line = & $Global:__SimReadLine + if (-not [string]::IsNullOrWhiteSpace($line)) { + $Global:__SimInCommand = $true + $Global:__SimLastExitCode = $global:LASTEXITCODE + $Global:__SimLastError = if ($global:Error.Count -gt 0) { $global:Error[0] } else { $null } + [Console]::Write((__Sim-Osc "E;$(__Sim-Escape $line)") + (__Sim-Osc 'C')) + } + $line + } + + $Global:__SimPrompt = $function:prompt + if (-not $Global:__SimPrompt) { $Global:__SimPrompt = { "PS $($PWD.Path)> " } } + function Global:prompt { + $succeeded = $global:? + $nativeExitCode = $global:LASTEXITCODE + # Cwd is reported before the finish marker so a cd is already visible by the time the + # command's result is resolved. + $markers = '' + if ($PWD.Provider.Name -eq 'FileSystem') { + $markers += (__Sim-Osc "P;Cwd=$(__Sim-Escape $PWD.ProviderPath)") + } + if ($Global:__SimInCommand) { + $Global:__SimInCommand = $false + $newError = $global:Error.Count -gt 0 -and -not [object]::ReferenceEquals($global:Error[0], $Global:__SimLastError) + $nativeRan = $nativeExitCode -is [int] -and ($nativeExitCode -ne $Global:__SimLastExitCode -or -not $newError) + $exitCode = if ($succeeded) { 0 } elseif ($nativeRan -and $nativeExitCode -ne 0) { $nativeExitCode } else { 1 } + $markers += (__Sim-Osc "D;$exitCode") + } + $markers += (__Sim-Osc 'A') + # Hands the user's prompt the $? it would have seen, for prompts that show the last result. + if (-not $succeeded) { Write-Error 'failure' -ErrorAction Ignore } + $userPrompt = (& $Global:__SimPrompt) -join '' + $markers + $userPrompt + (__Sim-Osc 'B') + } +} +` +} + +/** PowerShell's `-EncodedCommand` takes base64 of UTF-16LE text. */ +function encodePowerShellCommand(script: string): string { + return Buffer.from(script, 'utf16le').toString('base64') +} + export interface ShellLaunch { args: string[] env: Record @@ -301,6 +465,19 @@ export function buildShellLaunch( nonce: string, env: Record ): ShellLaunch { + if (shell === 'pwsh' || shell === 'powershell') { + return { + args: [ + '-NoLogo', + '-NoProfile', + '-NoExit', + '-EncodedCommand', + encodePowerShellCommand(powerShellScript(nonce)), + ], + env: {}, + } + } + mkdirSync(dir, { recursive: true }) if (shell === 'zsh') { @@ -312,7 +489,9 @@ export function buildShellLaunch( } const rcPath = writeBashFile(dir, nonce) + // Git Bash reads `C:/...` without the MS-DOS path warning `C:\...` can raise. + const initFile = sep === '\\' ? rcPath.replaceAll('\\', '/') : rcPath // `--init-file` is honoured only by interactive non-login bash, so the login // flag is deliberately omitted here; the generated file sources ~/.bashrc. - return { args: ['--init-file', rcPath, '-i'], env: {} } + return { args: ['--init-file', initFile, '-i'], env: {} } } diff --git a/apps/desktop/src/main/terminal/shell-path.test.ts b/apps/desktop/src/main/terminal/shell-path.test.ts new file mode 100644 index 00000000000..5450ebedf84 --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-path.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { resolveShellPath } from '@/main/terminal/shell-path' + +const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + +/** A Windows machine whose disk holds exactly `files`. */ +function windows(env: NodeJS.ProcessEnv, files: string[]) { + return resolveShellPath({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows', ...env }, + exists: (path) => files.includes(path), + }) +} + +describe('resolveShellPath on Windows', () => { + it('prefers PowerShell 7 on PATH over Windows PowerShell', () => { + const pwsh = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' + expect( + windows({ Path: 'C:\\Windows;C:\\Program Files\\PowerShell\\7\\' }, [ + pwsh, + WINDOWS_POWERSHELL, + ]) + ).toBe(pwsh) + }) + + it('finds PATH and SystemRoot whatever case Windows stored them in', () => { + const pwsh = 'D:\\pwsh\\pwsh.exe' + expect(windows({ PATH: 'D:\\pwsh' }, [pwsh])).toBe(pwsh) + expect( + resolveShellPath({ + platform: 'win32', + env: { SYSTEMROOT: 'E:\\Win' }, + exists: (path) => path === 'E:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + }) + ).toBe('E:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') + }) + + it('never takes a pwsh.exe from a relative PATH entry, which resolves against the cwd', () => { + expect( + windows({ Path: '.;tools' }, ['.\\pwsh.exe', 'tools\\pwsh.exe', WINDOWS_POWERSHELL]) + ).toBe(WINDOWS_POWERSHELL) + }) + + it('falls back to cmd when no PowerShell is installed', () => { + expect(windows({ ComSpec: 'C:\\Windows\\system32\\cmd.exe' }, [])).toBe( + 'C:\\Windows\\system32\\cmd.exe' + ) + }) + + it('runs a SHELL set to a Windows path, and ignores the POSIX one MSYS exports', () => { + const gitBash = 'C:\\Program Files\\Git\\bin\\bash.exe' + expect(windows({ SHELL: gitBash }, [gitBash, WINDOWS_POWERSHELL])).toBe(gitBash) + expect(windows({ SHELL: '/usr/bin/bash' }, ['/usr/bin/bash', WINDOWS_POWERSHELL])).toBe( + WINDOWS_POWERSHELL + ) + }) +}) diff --git a/apps/desktop/src/main/terminal/shell-path.ts b/apps/desktop/src/main/terminal/shell-path.ts new file mode 100644 index 00000000000..8964f1cac0d --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-path.ts @@ -0,0 +1,74 @@ +/** + * Which shell a new terminal runs. + * + * macOS and Linux run the user's login shell from `SHELL`. Windows has no such + * variable of its own, so the terminal picks the best shell installed: + * PowerShell 7 when it is on PATH, else the Windows PowerShell 5.1 every + * Windows ships, else cmd. + */ +import { win32 } from 'node:path' + +export interface ShellPathContext { + platform: NodeJS.Platform + env: NodeJS.ProcessEnv + exists(path: string): boolean +} + +/** A Windows path anchored to a drive (`C:\`) or a share (`\\server\share`). */ +const WINDOWS_ABSOLUTE_PATH = /^(?:[a-zA-Z]:[\\/]|\\\\)/ + +/** The shell program a new terminal spawns. */ +export function resolveShellPath(context: ShellPathContext): string { + if (context.platform !== 'win32') return context.env.SHELL || '/bin/zsh' + return configuredWindowsShell(context) ?? defaultWindowsShell(context) +} + +/** + * `SHELL` set to a Windows path, such as Git Bash's `bash.exe`. The MSYS + * environment exports a POSIX `SHELL` (`/usr/bin/bash`) that nothing outside + * it can launch, so only a Windows path that exists counts. + */ +function configuredWindowsShell({ env, exists }: ShellPathContext): string | null { + const shell = env.SHELL + return shell && WINDOWS_ABSOLUTE_PATH.test(shell) && exists(shell) ? shell : null +} + +/** `%SystemRoot%\System32`, named in full so a program on PATH cannot stand in for the system's. */ +export function windowsSystem32(env: NodeJS.ProcessEnv): string { + return win32.join(windowsEnv(env, 'SystemRoot') || 'C:\\Windows', 'System32') +} + +/** The Windows PowerShell 5.1 every Windows ships. */ +export function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return win32.join(windowsSystem32(env), 'WindowsPowerShell', 'v1.0', 'powershell.exe') +} + +function defaultWindowsShell(context: ShellPathContext): string { + const windowsPowerShell = windowsPowerShellPath(context.env) + return ( + findOnPath('pwsh.exe', context) ?? + (context.exists(windowsPowerShell) ? windowsPowerShell : null) ?? + (windowsEnv(context.env, 'ComSpec') || win32.join(windowsSystem32(context.env), 'cmd.exe')) + ) +} + +function findOnPath(program: string, { env, exists }: ShellPathContext): string | null { + for (const entry of (windowsEnv(env, 'Path') ?? '').split(';')) { + const directory = entry.trim().replace(/^"|"$/g, '') + if (!WINDOWS_ABSOLUTE_PATH.test(directory)) continue + const candidate = win32.join(directory, program) + if (exists(candidate)) return candidate + } + return null +} + +/** + * Reads a Windows environment variable, whose name is case-insensitive. A copy + * of `process.env` loses the case-insensitive lookup Node gives the original, + * and Windows itself stores `Path` and `SystemRoot` in mixed case. + */ +function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined { + const lower = name.toLowerCase() + const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === lower) + return key === undefined ? undefined : env[key] +} diff --git a/apps/desktop/src/main/terminal/tmux.ts b/apps/desktop/src/main/terminal/tmux.ts index 782b1a84990..4f50ff697b2 100644 --- a/apps/desktop/src/main/terminal/tmux.ts +++ b/apps/desktop/src/main/terminal/tmux.ts @@ -26,6 +26,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { generateRandomHex } from '@sim/utils/random' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopTmux') @@ -89,11 +90,6 @@ export interface TmuxAttachment { clientTty: string } -/** - * Runs one tmux command. Never throws: a missing binary, a dead server, and a - * bad target all arrive as `ok: false` with tmux's own message, which is more - * useful to the model than an exception. - */ /** * Set once a `tmux` spawn fails with ENOENT: the binary is not installed, and * it will not appear mid-session, so every later call short-circuits instead @@ -102,13 +98,21 @@ export interface TmuxAttachment { */ let tmuxBinaryMissing = false +const TMUX_SUPPORTED = desktopFeatures(process.platform).tmux + +/** Whether tmux is off the table: never attached on this platform, or not installed. */ export function isTmuxUnavailable(): boolean { - return tmuxBinaryMissing + return !TMUX_SUPPORTED || tmuxBinaryMissing } +/** + * Runs one tmux command. Never throws: a missing binary, a dead server, and a + * bad target all arrive as `ok: false` with tmux's own message, which is more + * useful to the model than an exception. + */ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise { return new Promise((resolve) => { - if (tmuxBinaryMissing) { + if (isTmuxUnavailable()) { resolve({ ok: false, stdout: '', stderr: 'tmux is not installed' }) return } diff --git a/apps/desktop/src/main/terminal/windows-shells.test.ts b/apps/desktop/src/main/terminal/windows-shells.test.ts new file mode 100644 index 00000000000..b5a644768e2 --- /dev/null +++ b/apps/desktop/src/main/terminal/windows-shells.test.ts @@ -0,0 +1,313 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { TerminalRunResult } from '@sim/terminal-protocol' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalService } from '@/main/terminal' + +/** + * The agent terminal against the real Windows shells: node-pty's ConPTY, the PowerShell and Git + * Bash integration, and the Windows Stop path, nothing stubbed. Runs only on Windows, and each + * shell only where it is installed (windows-latest has all three). + */ +const WINDOWS_POWERSHELL = join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' +) + +interface ShellUnderTest { + name: string + path: string + /** Prints `sim-ok-`. */ + succeed(n: number): string + fail: string + /** A cmdlet that fails, which sets no exit code of its own. */ + failingCmdlet?: string + /** A Windows path as a quoted argument this shell reads. */ + quote(path: string): string + cd: string + /** What runs a program named by a quoted path. */ + call: string + /** Starts a quoted program with quoted arguments in the background, as a user would. */ + background(program: string, args: string[]): string +} + +const POWERSHELL: Omit = { + succeed: (n) => `Write-Output sim-ok-${n}`, + fail: 'cmd /c exit 3', + failingCmdlet: 'Get-Item -LiteralPath C:\\sim-does-not-exist', + quote: (path) => `'${path}'`, + cd: 'Set-Location -LiteralPath', + call: '&', + background: (program, args) => + `Start-Process -WindowStyle Hidden -FilePath ${program} -ArgumentList ${args.join(',')}`, +} + +const SHELLS: ShellUnderTest[] = [ + { name: 'PowerShell 7', path: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', ...POWERSHELL }, + { name: 'Windows PowerShell 5.1', path: WINDOWS_POWERSHELL, ...POWERSHELL }, + { + name: 'Git Bash', + path: 'C:\\Program Files\\Git\\bin\\bash.exe', + succeed: (n) => `echo sim-ok-${n}`, + fail: "sh -c 'exit 3'", + quote: (path) => `'${path.replaceAll('\\', '/')}'`, + cd: 'cd', + call: '', + background: (program, args) => `${program} ${args.join(' ')} &`, + }, +] + +/** Ctrl-C reaches this as a keystroke rather than a signal, so it cannot interrupt it. */ +const STUBBORN_SCRIPT = `param($PidFile) +[Console]::TreatControlCAsInput = $true +Set-Content -LiteralPath $PidFile -Value $PID +Start-Sleep -Seconds 120 +` + +/** + * A server left running in the background: up at once, it starts a worker of its own only once + * `TriggerFile` appears, so the worker is newer than a command that runs meanwhile. + */ +const BACKGROUND_SCRIPT = `param($PidFile, $TriggerFile, $WorkerScript, $WorkerPidFile) +[Console]::TreatControlCAsInput = $true +Set-Content -LiteralPath $PidFile -Value $PID +$deadline = (Get-Date).AddSeconds(60) +while (-not (Test-Path -LiteralPath $TriggerFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 100 } +Start-Process -WindowStyle Hidden -FilePath (Join-Path $PSHOME 'powershell.exe') -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $WorkerScript + '"'), ('"' + $WorkerPidFile + '"') +Start-Sleep -Seconds 120 +` + +/** Enough runs that output lost to a reordering ConPTY (a quarter of runs or more) shows. */ +const OUTPUT_RUNS = 12 + +/** A shell's first start on a cold CI runner, then the Stop escalation, take a while. */ +const SHELL_TEST_TIMEOUT_MS = 90_000 + +function scratchDirectory(): string { + return realpathSync.native(mkdtempSync(join(tmpdir(), 'sim-windows-shell-'))) +} + +/** The pid a stubborn script wrote once it is up. */ +async function readPid(pidFile: string): Promise { + const pid = () => (existsSync(pidFile) ? Number.parseInt(readFileSync(pidFile, 'utf8'), 10) : 0) + await vi.waitFor(() => expect(pid()).toBeGreaterThan(0), { timeout: 30_000, interval: 100 }) + return pid() +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function run( + service: TerminalService, + toolCallId: string, + command: string +): Promise { + const response = await service.executeTool(toolCallId, 'run', { command, waitSeconds: 60 }) + expect(response).toMatchObject({ ok: true }) + return response.result as TerminalRunResult +} + +describe.runIf(process.platform === 'win32')('the agent terminal on Windows', () => { + let service: TerminalService | null = null + + afterEach(() => { + service?.dispose() + service = null + }) + + function open(shell: ShellUnderTest, cwd = scratchDirectory()): TerminalService { + vi.stubEnv('SHELL', shell.path) + service = new TerminalService({ loadCwd: () => cwd }) + return service + } + + describe.each(SHELLS)('$name', (shell) => { + const installed = existsSync(shell.path) + + it.runIf(installed)( + "reports each successful command's output and exit code", + async () => { + const terminal = open(shell) + // Repeated: a ConPTY that reorders output past the finish marker loses it only some runs. + for (let n = 0; n < OUTPUT_RUNS; n++) { + const result = await run(terminal, `call-ok-${n}`, shell.succeed(n)) + + expect(result).toMatchObject({ status: 'completed', exitCode: 0 }) + expect(result.output).toContain(`sim-ok-${n}`) + } + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed)( + "reports a failing program's own exit code", + async () => { + const result = await run(open(shell), 'call-fail', shell.fail) + + expect(result).toMatchObject({ status: 'completed', exitCode: 3 }) + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed && shell.failingCmdlet !== undefined)( + "reports a failing cmdlet as 1, never an earlier program's code, and keeps that code for the user", + async () => { + const terminal = open(shell) + expect(await run(terminal, 'call-native', shell.fail)).toMatchObject({ exitCode: 3 }) + + const result = await run(terminal, 'call-cmdlet', shell.failingCmdlet ?? '') + + expect(result).toMatchObject({ status: 'completed', exitCode: 1 }) + const after = await run(terminal, 'call-code', 'Write-Output "code-$LASTEXITCODE"') + expect(after.output).toContain('code-3') + // The same failing code again is still the program's, not a stale one. + expect(await run(terminal, 'call-native-again', shell.fail)).toMatchObject({ exitCode: 3 }) + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed)( + 'follows the working directory across cd, in Windows form', + async () => { + const directory = join(scratchDirectory(), 'my project') + mkdirSync(directory) + + const result = await run(open(shell), 'call-cd', `${shell.cd} ${shell.quote(directory)}`) + + expect(result.exitCode).toBe(0) + expect(result.cwd?.toLowerCase()).toBe(directory.toLowerCase()) + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed)( + 'Stop ends a command that ignores Ctrl-C, spares earlier background work and its new workers, and leaves the shell usable', + async () => { + const scratch = scratchDirectory() + const script = join(scratch, 'stubborn.ps1') + const backgroundScript = join(scratch, 'background.ps1') + const backgroundPidFile = join(scratch, 'background-pid') + const triggerFile = join(scratch, 'start-worker') + const workerPidFile = join(scratch, 'worker-pid') + const pidFile = join(scratch, 'pid') + writeFileSync(script, STUBBORN_SCRIPT) + writeFileSync(backgroundScript, BACKGROUND_SCRIPT) + const terminal = open(shell, scratch) + const stubbornArgs = (pidPath: string) => [ + "'-NoProfile'", + "'-ExecutionPolicy'", + "'Bypass'", + "'-File'", + shell.quote(script), + shell.quote(pidPath), + ] + const command = [shell.call, shell.quote(WINDOWS_POWERSHELL), ...stubbornArgs(pidFile)] + .filter(Boolean) + .join(' ') + + // A server the user left running in this shell before the agent's command. + await run( + terminal, + 'call-background', + shell.background(shell.quote(WINDOWS_POWERSHELL), [ + "'-NoProfile'", + "'-ExecutionPolicy'", + "'Bypass'", + "'-File'", + shell.quote(backgroundScript), + shell.quote(backgroundPidFile), + shell.quote(triggerFile), + shell.quote(script), + shell.quote(workerPidFile), + ]) + ) + const backgroundPid = await readPid(backgroundPidFile) + let workerPid = 0 + try { + const running = run(terminal, 'call-stubborn', command) + const pid = await readPid(pidFile) + // The server starts a worker while the agent's command runs. + writeFileSync(triggerFile, '') + workerPid = await readPid(workerPidFile) + + await expect(terminal.cancelTool('call-stubborn')).resolves.toBe(true) + expect(await running).toMatchObject({ status: 'completed' }) + expect(isAlive(pid)).toBe(false) + expect(isAlive(backgroundPid)).toBe(true) + expect(isAlive(workerPid)).toBe(true) + expect(await run(terminal, 'call-after', shell.succeed(0))).toMatchObject({ + exitCode: 0, + }) + } finally { + for (const leftover of [workerPid, backgroundPid]) { + if (leftover > 0 && isAlive(leftover)) process.kill(leftover) + } + } + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed)( + "a terminating kill ends the agent's command, never the shell", + async () => { + const scratch = scratchDirectory() + const script = join(scratch, 'stubborn.ps1') + const pidFile = join(scratch, 'pid') + writeFileSync(script, STUBBORN_SCRIPT) + const terminal = open(shell, scratch) + const command = [ + shell.call, + shell.quote(WINDOWS_POWERSHELL), + "'-NoProfile'", + "'-ExecutionPolicy'", + "'Bypass'", + "'-File'", + shell.quote(script), + shell.quote(pidFile), + ] + .filter(Boolean) + .join(' ') + + const handedBack = await terminal.executeTool('call-stubborn', 'run', { + command, + waitSeconds: 1, + }) + expect(handedBack).toMatchObject({ ok: true, result: { status: 'running' } }) + const pid = await readPid(pidFile) + + const killed = await terminal.executeTool('call-kill', 'kill', { signal: 'SIGTERM' }) + + expect(killed).toMatchObject({ ok: true, result: { signalled: true } }) + await vi.waitFor(() => expect(isAlive(pid)).toBe(false), { timeout: 15_000 }) + await vi.waitFor( + async () => { + const after = await terminal.executeTool('call-after', 'run', { + command: shell.succeed(0), + waitSeconds: 30, + }) + expect(after).toMatchObject({ ok: true, result: { exitCode: 0 } }) + }, + { timeout: 30_000, interval: 500 } + ) + }, + SHELL_TEST_TIMEOUT_MS + ) + }) +}) diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index d17830b2df7..719b79f4128 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -13,6 +13,7 @@ function makeDeps(overrides: Partial = {}): TrayDeps { appOrigin: () => 'https://sim.ai', lastRoute: () => '/workspace/ws1/home', openMainWindow: vi.fn(), + platform: 'darwin', ...overrides, } } @@ -140,4 +141,33 @@ describe('installTray', () => { const tray = Tray.instances[0] expect(JSON.stringify(tray.setContextMenu.mock.calls.at(-1)?.[0])).not.toContain('Secret') }) + + it('opens Sim on a left-click of the Windows notification-area icon', () => { + // Windows shows the attached menu only on right-click, so without this a + // left-click on the icon does nothing at all. + const resized = { + addRepresentation: vi.fn(), + toPNG: vi.fn(() => Buffer.alloc(0)), + setTemplateImage: vi.fn(), + } + vi.mocked(nativeImage.createFromPath).mockReturnValueOnce({ + isEmpty: () => false, + resize: () => resized, + } as never) + vi.mocked(session.fromPartition).mockReturnValue({ + fetch: vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ chats: [] }) })), + } as never) + const openMainWindow = vi.fn() + + const handle = installTray(makeDeps({ platform: 'win32', openMainWindow })) + try { + const tray = Tray.instances[0] + const onClick = tray.on.mock.calls.find(([event]) => event === 'click')?.[1] + onClick?.() + expect(openMainWindow).toHaveBeenCalledWith() + expect(resized.setTemplateImage).not.toHaveBeenCalled() + } finally { + handle?.destroy() + } + }) }) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 7ae06b73c72..1da194ddc77 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -4,7 +4,8 @@ import { truncate } from '@sim/utils/string' import type { MenuItemConstructorOptions, NativeImage } from 'electron' import { app, Menu, nativeImage, session, Tray } from 'electron' import { newChatRoute } from '@/main/app-routes' -import { APP_NAME_FOR_CHANNEL, channelForOrigin, type DesktopChannel } from '@/main/config' +import { channelForOrigin } from '@/main/config' +import { CHANNEL_IDENTITIES, type DesktopChannel } from '@/shared/channel-identity' const logger = createLogger('DesktopTray') @@ -25,6 +26,9 @@ const TRAY_SUBSCRIPT_WIDTH = 5 const TRAY_SUBSCRIPT_HEIGHT = 7 const TRAY_SUBSCRIPT_STROKE_WIDTH = 1.1 const TRAY_SUBSCRIPT_SUPERSAMPLE = 4 +/** Notification-area icon edge (DIP) and the display scales Windows commonly runs at. */ +const WINDOWS_TRAY_ICON_SIZE = 16 +const WINDOWS_TRAY_SCALE_FACTORS = [1.25, 1.5, 2] as const type TrayEnvironmentMarker = 'L' | 'D' | 'S' type GlyphPoint = { x: number; y: number } @@ -332,6 +336,8 @@ export interface TrayDeps { appOrigin: () => string lastRoute: () => string | undefined openMainWindow: (route?: string) => void + /** Injectable for platform-specific tray tests. */ + platform?: NodeJS.Platform } function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions { @@ -391,27 +397,61 @@ export interface TrayHandle { destroy(): void } -/** - * The macOS status item owns an attached native menu, so macOS handles its - * selected/open state and a second icon click closes it normally. Recent chats - * refresh independently; each result swaps the attached menu for the next - * open without putting network or menu construction on the click path. - */ -export function installTray(deps: TrayDeps): TrayHandle | null { +/** Monochrome template glyph that macOS tints for the menu bar, with the environment subscript. */ +function macTrayIcon(channel: DesktopChannel): NativeImage | null { const iconPath = join(app.getAppPath(), 'static', 'tray', 'simTemplate.png') const sourceIcon = nativeImage.createFromPath(iconPath) if (sourceIcon.isEmpty()) { logger.error('Tray icon missing; skipping tray install', { iconPath }) return null } - const channel = channelForOrigin(deps.appOrigin()) const marker = trayEnvironmentMarker(channel) const icon = marker ? addTrayEnvironmentSubscript(sourceIcon, marker) : sourceIcon icon.setTemplateImage(true) + return icon +} + +/** + * Full-colour app icon for the Windows notification area, which never tints + * template images (a black glyph vanishes on a dark taskbar). The per-channel + * app icon already marks its environment, so no subscript is drawn. Each + * scale gets its own downsample so the icon stays crisp at 125-200% DPI. + */ +function windowsTrayIcon(channel: DesktopChannel): NativeImage | null { + const iconPath = join(app.getAppPath(), 'static', CHANNEL_IDENTITIES[channel].dockIcon) + const source = nativeImage.createFromPath(iconPath) + if (source.isEmpty()) { + logger.error('Tray icon missing; skipping tray install', { iconPath }) + return null + } + const sized = (scale: number) => { + const edge = Math.round(WINDOWS_TRAY_ICON_SIZE * scale) + return source.resize({ width: edge, height: edge, quality: 'best' }) + } + const icon = sized(1) + for (const scaleFactor of WINDOWS_TRAY_SCALE_FACTORS) { + icon.addRepresentation({ scaleFactor, buffer: sized(scaleFactor).toPNG() }) + } + return icon +} + +/** + * The macOS status item owns an attached native menu, so macOS handles its + * selected/open state and a second icon click closes it normally. Recent chats + * refresh independently; each result swaps the attached menu for the next + * open without putting network or menu construction on the click path. + */ +export function installTray(deps: TrayDeps): TrayHandle | null { + const channel = channelForOrigin(deps.appOrigin()) + const isWindows = (deps.platform ?? process.platform) === 'win32' + const icon = isWindows ? windowsTrayIcon(channel) : macTrayIcon(channel) + if (!icon) return null const tray = new Tray(icon) tray.setIgnoreDoubleClickEvents(true) - tray.setToolTip(APP_NAME_FOR_CHANNEL[channel]) + tray.setToolTip(CHANNEL_IDENTITIES[channel].name) + // Windows opens the context menu only on right-click; a left-click is "show me Sim". + if (isWindows) tray.on('click', () => deps.openMainWindow()) let refreshPromise: Promise | null = null let cacheGeneration = 0 diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 723cc3d38ae..dcbd5272920 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -16,6 +16,7 @@ const autoUpdaterMock = { autoDownload: true, autoInstallOnAppQuit: false, autoRunAppAfterInstall: true, + disableWebInstaller: false, logger: null as unknown, on: vi.fn(), setFeedURL: vi.fn(), @@ -116,6 +117,7 @@ describe('initUpdater state machine', () => { withRunningWork?: string /** Overrides the in-flight check, e.g. to make it fail. */ describeInFlightWork?: () => Promise + platform?: NodeJS.Platform }) { const states: DesktopUpdateState[] = [] const handle = initUpdater({ @@ -128,7 +130,7 @@ 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', + platform: options?.platform ?? 'darwin', beforeInstall: options?.beforeInstall, setRelaunchPending: options?.setRelaunchPending, describeInFlightWork: @@ -150,6 +152,8 @@ describe('initUpdater state machine', () => { autoUpdaterMock.downloadUpdate.mockClear() autoUpdaterMock.quitAndInstall.mockClear() autoUpdaterMock.autoRunAppAfterInstall = false + autoUpdaterMock.autoInstallOnAppQuit = false + autoUpdaterMock.disableWebInstaller = false updaterChannel = '' vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false }) }) @@ -665,6 +669,133 @@ describe('initUpdater state machine', () => { await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) + + describe('on Windows (NSIS)', () => { + const windowsAsset = (version: string) => + `https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-x64.exe` + + it('offers a verified download for restart without waiting for a native stage', async () => { + const { handle, states } = await createUpdater({ platform: 'win32' }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + expect(states.at(-1)).toEqual({ status: 'ready', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + expect(autoUpdaterMock.disableWebInstaller).toBe(true) + expect(events.record).toHaveBeenCalledWith('update_downloaded', { version: '2.0.0' }) + expect(squirrelUpdater.on).not.toHaveBeenCalled() + }) + + async function readyWindowsUpdater(withRunningWork?: string) { + const { handle } = await createUpdater({ platform: 'win32', withRunningWork }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + return handle + } + + it('installs silently and relaunches without a prompt when nothing is running', async () => { + const handle = await readyWindowsUpdater() + + handle.install() + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(true, true) + }) + + it('installs silently and relaunches once restarting over running work is confirmed', async () => { + const handle = await readyWindowsUpdater('claude is still running in a terminal.') + + handle.install() + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(true, true) + }) + + it('installs a required update silently without asking about running work', async () => { + const handle = await readyWindowsUpdater('claude is still running in a terminal.') + + handle.install({ mandatory: true }) + await vi.advanceTimersByTimeAsync(0) + + expect(dialog.showMessageBox).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(true, true) + }) + + it('replaces a ready update with a newer verified release', async () => { + const { handle } = await createUpdater({ platform: 'win32' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + emit('update-available', { version: '2.1.0' }) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.1.0' }) + emit('update-downloaded', { version: '2.1.0' }) + + expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' }) + }) + + it('keeps a ready update when a later download fails, but not when the installer fails', async () => { + const { handle } = await createUpdater({ platform: 'win32' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + autoUpdaterMock.downloadUpdate.mockRejectedValueOnce(new Error('download interrupted')) + emit('error', new Error('download interrupted')) + emit('update-available', { version: '2.1.0' }) + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + + handle.install() + await vi.advanceTimersByTimeAsync(0) + emit('error', new Error('No update filepath provided')) + + expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + }) + + it('accepts the Windows installer from an origin feed', async () => { + const probeOriginFeed = vi.fn(async () => true) + const { handle } = await createUpdater({ platform: 'win32', probeOriginFeed }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { + version: '2.0.0', + files: [{ url: windowsAsset('2.0.0'), sha512: 'checksum' }], + }) + + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' }) + }) + + it('blocks a macOS artifact offered to a Windows shell', async () => { + const { handle } = await createUpdater({ platform: 'win32', 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: 'idle' }) + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + }) + }) }) describe('readUpdateManifest', () => { @@ -827,6 +958,56 @@ describe('initUpdater manual mode (no Developer ID signature)', () => { expect(shell.openExternal).not.toHaveBeenCalled() }) + it('offers the Windows installer from the Windows manifest', async () => { + const windowsManifest = [ + 'version: 9.9.9', + 'files:', + ' - url: https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-x64.exe', + ' sha512: abc', + "releaseDate: '2026-07-23T00:00:00.000Z'", + ].join('\n') + const fetchManifest = vi.fn(async () => windowsManifest) + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + canSelfUpdate: async () => false, + fetchManifest, + platform: 'win32', + }) + await vi.advanceTimersByTimeAsync(0) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(fetchManifest).toHaveBeenCalledWith( + 'https://www.dev.sim.ai/api/desktop/update/latest.yml' + ) + handle.install() + + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-x64.exe' + ) + }) + + it('never offers a macOS download to a Windows shell', async () => { + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + canSelfUpdate: async () => false, + fetchManifest: async () => manifest('9.9.9'), + platform: 'win32', + }) + await vi.advanceTimersByTimeAsync(0) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toMatchObject({ status: 'error', manual: true }) + handle.install() + expect(shell.openExternal).not.toHaveBeenCalled() + }) + it('refuses assets from other repositories on github.com', async () => { const offRepository = manifest('9.9.9', 'simstudioai/not-desktop-releases') const { handle } = await createManualUpdater(async () => offRepository) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 1c084971a27..edcbb255dc9 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,6 +1,11 @@ import { execFile } from 'node:child_process' -import { unlinkSync } from 'node:fs' -import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { existsSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { + DESKTOP_UPDATE_ARTIFACTS, + type DesktopUpdateArtifacts, + type DesktopUpdateState, +} from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage } from '@sim/utils/errors' @@ -59,7 +64,7 @@ export async function readUpdateManifest(response: Response): Promise = { dev: 'simstudioai/sim-desktop-releases', } -function isReleaseAssetUrl(rawUrl: string, version: string, channel: UpdateChannel): boolean { +/** + * What differs between the platforms that self-update: the manifest + * electron-updater requests, the artifacts a release may point it at, and how a + * downloaded update becomes installable. + */ +interface UpdatePlatform extends DesktopUpdateArtifacts { + /** + * macOS hands the downloaded zip to Squirrel.Mac, which stages it and only + * then reports `update-downloaded` on Electron's native updater. NSIS has no + * native step: electron-updater's own `update-downloaded` fires after it has + * verified the installer's Authenticode publisher. + */ + nativeStaging: boolean +} + +const MAC_UPDATE_PLATFORM: UpdatePlatform = { + ...DESKTOP_UPDATE_ARTIFACTS.mac, + nativeStaging: true, +} + +const WINDOWS_UPDATE_PLATFORM: UpdatePlatform = { + ...DESKTOP_UPDATE_ARTIFACTS.windows, + nativeStaging: false, +} + +/** The update pipeline for a platform, or null where the shell does not update itself. */ +function updatePlatformFor(platform: NodeJS.Platform): UpdatePlatform | null { + if (platform === 'darwin') return MAC_UPDATE_PLATFORM + if (platform === 'win32') return WINDOWS_UPDATE_PLATFORM + return null +} + +function isReleaseAssetUrl( + rawUrl: string, + version: string, + channel: UpdateChannel, + platform: UpdatePlatform +): boolean { if (!isSafeExternalUrl(rawUrl)) return false try { const url = new URL(rawUrl) @@ -96,10 +138,7 @@ function isReleaseAssetUrl(rawUrl: string, version: string, channel: UpdateChann 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`, - ]) + const expectedAssetNames = new Set(platform.artifactNames(normalizedVersion)) return ( url.origin === RELEASE_ASSET_ORIGIN && url.username === '' && @@ -243,13 +282,13 @@ export interface UpdaterDeps { loadAutoUpdater?: () => typeof import('electron-updater')['autoUpdater'] /** Test seam: overrides the origin feed availability probe. */ probeOriginFeed?: (feedUrl: string) => Promise - /** Test seam: overrides Applications-folder and Developer ID eligibility detection. */ + /** Test seam: overrides the platform's self-update eligibility detection. */ canSelfUpdate?: () => Promise /** Test seam: overrides the manual-mode manifest fetch (body or null). */ fetchManifest?: (url: string) => Promise - /** Test seam for the macOS-only updater gate. */ + /** Test seam for the platform gate: macOS and Windows update, other platforms do not. */ platform?: NodeJS.Platform - /** Flushes desktop-owned state before Squirrel terminates the process. */ + /** Flushes desktop-owned state before the installer terminates the process. */ beforeInstall?: () => Promise /** Bypasses renderer unload guards once a relaunch is committed. */ setRelaunchPending?: (pending: boolean) => void @@ -311,12 +350,15 @@ function isValidUpdateCandidate(candidateVersion: string, currentVersion: string /** * Squirrel.Mac can update only an app installed under /Applications whose - * running bundle carries a Developer ID TeamIdentifier. Other packaged builds - * use the manual-download pipeline. + * running bundle carries a Developer ID TeamIdentifier. On Windows the NSIS + * updater needs the `app-update.yml` the installer build writes into + * resources — it carries the publisher name the downloaded installer's + * signature is checked against. Other packaged builds use the manual-download + * pipeline. */ -async function detectSelfUpdateCapability(): Promise { - if (process.platform !== 'darwin') { - return true +async function detectSelfUpdateCapability(platform: NodeJS.Platform): Promise { + if (platform === 'win32') { + return app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')) } if (!app.isInApplicationsFolder()) { return false @@ -363,7 +405,9 @@ interface UpdateEngine { * as manual downloads. */ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { - if ((deps.platform ?? process.platform) !== 'darwin') { + const platformName = deps.platform ?? process.platform + const platform = updatePlatformFor(platformName) + if (!platform) { return NOOP_UPDATER_HANDLE } if (!app.isPackaged && !deps.loadAutoUpdater && !deps.canSelfUpdate) { @@ -432,8 +476,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let autoDownloadEnabled = deps.autoDownload?.() ?? true // Prevents the library from fetching a candidate before Sim validates its asset URLs. autoUpdater.autoDownload = false - // Explicit Update actions must reopen Sim after Squirrel swaps the bundle. + // Explicit Update actions must reopen Sim after the installer swaps the app. autoUpdater.autoRunAppAfterInstall = true + // Releases ship a full installer; there is no web-installer package to fetch. + if (!platform.nativeStaging) autoUpdater.disableWebInstaller = true // Never install without vetting the downloaded version first. Enabled per // download in the update-downloaded handler, but only for accepted updates // — so a blocked/downgrade build that was already downloaded is never @@ -466,7 +512,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { from: currentVersion, version: state.version ?? '', }) - autoUpdater.quitAndInstall() + if (platform.nativeStaging) { + autoUpdater.quitAndInstall() + } else { + // Silent, then relaunch: the one-click installer shows no UI to confirm. + autoUpdater.quitAndInstall(true, true) + } }) .catch((error) => { deps.setRelaunchPending?.(false) @@ -615,7 +666,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const validOriginAssets = !originFeedConfigured || (info.files.length > 0 && - info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel))) + info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel, platform))) const validCandidate = validOriginAssets && isValidUpdateCandidate(info.version, currentVersion) if (state.status === 'ready') { @@ -669,9 +720,24 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) }) + /** A verified update is installable: persist the checkpoint and offer the restart. */ + const markReady = (version: string) => { + stagedVersion = version + if (deps.installStatePath) { + try { + writeJsonFileAtomicallySync(deps.installStatePath, { version }) + } catch (error) { + logger.warn('Could not persist update installation checkpoint', { error }) + } + } + deps.events.record('update_downloaded', { version }) + setState({ status: 'ready', version }) + } + autoUpdater.on('update-downloaded', (info) => { if (state.status !== 'downloading') return - // MacUpdater has replaced the native feed before emitting this event. + // MacUpdater has replaced the native feed before emitting this event, and + // NsisUpdater has replaced the cached installer. stagedVersion = null clearInstallState() if ( @@ -685,37 +751,16 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return } acceptedUpdateVersion = null - pendingStagingVersion = info.version autoUpdater.autoInstallOnAppQuit = true - setState({ status: 'downloading', version: info.version, percent: 100 }) - }) - - squirrelUpdater.on('update-downloaded', () => { - const version = pendingStagingVersion - if (version === null || state.status !== 'downloading') return - pendingStagingVersion = null - stagedVersion = version - if (deps.installStatePath) { - try { - writeJsonFileAtomicallySync(deps.installStatePath, { version }) - } catch (error) { - logger.warn('Could not persist update installation checkpoint', { error }) - } + if (!platform.nativeStaging) { + markReady(info.version) + return } - deps.events.record('update_downloaded', { version }) - setState({ status: 'ready', version }) + pendingStagingVersion = info.version + setState({ status: 'downloading', version: info.version, percent: 100 }) }) - /** Network failures belong to their request promises; native errors invalidate staging. */ - squirrelUpdater.on('error', (error) => { - if ( - state.status !== 'downloading' && - state.status !== 'ready' && - !installInFlight && - !relaunchRequested - ) { - return - } + const failNativeInstall = (error: unknown) => { installInFlight = false relaunchRequested = false stagedVersion = null @@ -727,13 +772,42 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { logger.warn('Native update failed', { message }) deps.events.record('update_error', { message }) setState({ status: 'error', version: state.version }) - }) + } + + if (platform.nativeStaging) { + squirrelUpdater.on('update-downloaded', () => { + const version = pendingStagingVersion + if (version === null || state.status !== 'downloading') return + pendingStagingVersion = null + markReady(version) + }) + + // Network failures belong to their request promises; native errors invalidate staging. + squirrelUpdater.on('error', (error) => { + if ( + state.status !== 'downloading' && + state.status !== 'ready' && + !installInFlight && + !relaunchRequested + ) { + return + } + failNativeInstall(error) + }) + } else { + // Without a native stage, only launching the installer can fail here; + // download and signature failures reject their request promises. + autoUpdater.on('error', (error) => { + if (!installInFlight && !relaunchRequested) return + failNativeInstall(error) + }) + } /** * Prefer the per-environment feed served by the configured origin; fall * back to the packaged GitHub feed when the origin doesn't serve one (an * older or partial deployment). The origin feed is channel-resolved - * server-side, so the client always requests plain `latest-mac.yml`. + * server-side, so the client always requests the plain platform manifest. * * The fallback is stable-only: prerelease (dev/staging) builds exist * solely on their origin feed, and the GitHub feed could only offer them @@ -744,7 +818,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const probeOriginFeed = deps.probeOriginFeed ?? (async (feedUrl: string) => { - const response = await net.fetch(`${feedUrl}/latest-mac.yml`, { + const response = await net.fetch(`${feedUrl}/${platform.manifest}`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), }) if (response.ok) return true @@ -908,7 +982,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }, UPDATE_CHECK_TIMEOUT_MS) try { const feedUrl = feedUrlForOrigin(deps.appOrigin()) - const manifest = feedUrl ? await fetchManifest(`${feedUrl}/latest-mac.yml`) : null + const manifest = feedUrl ? await fetchManifest(`${feedUrl}/${platform.manifest}`) : null if (activeCheckId !== checkId) return const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null if (!manifest || !version || !isValidUpdateCandidate(version, currentVersion)) { @@ -917,7 +991,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } if (refreshing && !isNewerVersion(version, offeredVersion)) return // The feed rewrites manifest urls to absolute GitHub asset URLs; - // prefer the dmg for a human download. + // prefer the platform's most human-installable artifact (the dmg on + // macOS). // // Filtered before selection, not just before opening: the manifest is // whatever the configured origin served, so `smb://…/x.dmg` or a bare @@ -926,10 +1001,14 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const urls = Array.from( manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm), (m) => m[1] - ).filter((url) => isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion))) + ).filter((url) => + isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion), platform) + ) const nextDownloadUrl = - urls.find((url) => url.endsWith('.dmg')) ?? - urls.find((url) => url.endsWith('.zip')) ?? + platform + .artifactNames(version.replace(/^v/, '')) + .map((name) => urls.find((url) => url.endsWith(`/${encodeURIComponent(name)}`))) + .find((url) => url !== undefined) ?? urls[0] ?? null if (!nextDownloadUrl) { @@ -984,7 +1063,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let pendingAutoDownload: boolean | null = null let pendingInteractiveCheck = false - const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability + const canSelfUpdate = deps.canSelfUpdate ?? (() => detectSelfUpdateCapability(platformName)) void canSelfUpdate() .catch((error) => { logger.warn('Could not detect self-update capability; using manual updates', { diff --git a/apps/desktop/src/main/win32-native-files.test.ts b/apps/desktop/src/main/win32-native-files.test.ts new file mode 100644 index 00000000000..e7e9e1471bd --- /dev/null +++ b/apps/desktop/src/main/win32-native-files.test.ts @@ -0,0 +1,260 @@ +import { + mkdir, + mkdtemp, + open, + opendir, + realpath, + rename, + rm, + stat, + symlink, + unlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { app } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { type NativeFileHandle, openPosixNativeFile } from '@/main/native-directory' +import { isWithinRoot } from '@/main/path-containment' +import { openWin32NativeFile, type Win32NativeFileSystem } from '@/main/win32-native-files' + +/* + * How the Windows backend could fail, one test each: + * 1. a junction inside the grant that points outside is opened, or followed by a listing; + * 2. a file beneath a junction parent resolves outside the grant and is opened; + * 3. a file replaced between its identity check and the open is read; + * 4. a case-variant spelling of the grant is refused, or a case-variant sibling + * (`Root2` beside `Root`) is accepted; + * 5. listing caps or the truncation flag drift from the POSIX module's; + * 6. a directory replaced while it is listed returns the replacement's names; + * 7. a reserved name, stream, or trailing dot reaches the filesystem; + * 8. a directory opened as a file fails differently than on POSIX; + * 9. a granted folder replaced since the grant is still opened; + * 10. a junction pointing back inside the grant is followed, unlike the POSIX module; + * 11. a path swapped for a junction leading outside, after its identity check, stays open; + * 12. a granted folder replaced while a path beneath it is opened stays open. + * A link to a network share, or a path resolving to one, is `localRealpath`'s to refuse. + */ + +const isWindows = process.platform === 'win32' + +/** Both backends run where they can; the POSIX native module is not built on Windows. */ +const BACKENDS: Array<[string, typeof openWin32NativeFile]> = [['Windows', openWin32NativeFile]] +if (!isWindows) BACKENDS.push(['POSIX', openPosixNativeFile]) + +/** A junction on Windows, which needs no privilege; a symlink elsewhere. */ +function linkDirectory(target: string, path: string) { + return symlink(target, path, isWindows ? 'junction' : 'dir') +} + +async function readAll(handle: NativeFileHandle): Promise { + try { + const buffer = Buffer.alloc(64) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + return buffer.toString('utf8', 0, bytesRead) + } finally { + await handle.close() + } +} + +async function list(handle: NativeFileHandle) { + try { + return await handle.readDirectory(1000) + } finally { + await handle.close() + } +} + +let base: string +let root: string +let outside: string + +beforeEach(async () => { + vi.mocked(app.getAppPath).mockReturnValue(fileURLToPath(new URL('../..', import.meta.url))) + base = await realpath(await mkdtemp(join(tmpdir(), 'sim-win32-files-'))) + root = join(base, 'Root') + outside = join(base, 'Outside') + await mkdir(root) + await mkdir(outside) + await writeFile(join(outside, 'secret.txt'), 'outside') +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +async function openBeneath(relativePath: string, directory = false, fs?: Win32NativeFileSystem) { + return openWin32NativeFile(root, relativePath, await stat(root, { bigint: true }), directory, fs) +} + +describe('the Windows native file backend', () => { + it('refuses a junction inside the grant that points outside, and lists it as a link', async () => { + await linkDirectory(outside, join(root, 'escape')) + + await expect(openBeneath('escape', true)).rejects.toThrow('within the approved folder') + expect(await list(await openBeneath('', true))).toEqual({ + entries: [{ name: 'escape', kind: 'symlink' }], + truncated: false, + }) + }) + + it('refuses a file whose parent is a junction leading outside the grant', async () => { + await linkDirectory(outside, join(root, 'docs')) + + await expect(openBeneath(join('docs', 'secret.txt'))).rejects.toThrow( + 'within the approved folder' + ) + }) + + it('refuses a junction that points back inside the grant', async () => { + await mkdir(join(root, 'inner')) + await linkDirectory(join(root, 'inner'), join(root, 'alias')) + + await expect(openBeneath('alias', true)).rejects.toThrow('within the approved folder') + }) + + it('refuses a directory swapped for a junction leading outside once it is open', async () => { + await mkdir(join(root, 'docs')) + const openThenSwap: Win32NativeFileSystem = { + open: async (path) => { + const handle = await open(path, 'r') + await rename(path, join(outside, 'docs')) + await linkDirectory(join(outside, 'docs'), path) + return handle + }, + opendir, + } + + await expect(openBeneath('docs', true, openThenSwap)).rejects.toThrow( + 'within the approved folder' + ) + }) + + it('refuses a path opened after its granted folder was replaced', async () => { + await mkdir(join(root, 'docs')) + // Windows refuses to rename a folder holding an open handle, so the root is swapped before the + // open; moving `docs` keeps its identity, leaving the root check as the only one that differs. + const replaceRootThenOpen: Win32NativeFileSystem = { + open: async (path) => { + await rename(join(root, 'docs'), join(base, 'docs-moving')) + await rename(root, join(base, 'Root-before')) + await mkdir(root) + await rename(join(base, 'docs-moving'), join(root, 'docs')) + return open(path, 'r') + }, + opendir, + } + + await expect(openBeneath('docs', true, replaceRootThenOpen)).rejects.toThrow( + 'The approved folder changed. Request access again.' + ) + }) + + it('refuses a file replaced between its identity check and the open', async () => { + await writeFile(join(root, 'notes.txt'), 'original') + const replaceThenOpen: Win32NativeFileSystem = { + open: async (path) => { + await rename(path, join(base, 'moved.txt')) + await writeFile(path, 'replacement') + return open(path, 'r') + }, + opendir, + } + + await expect(openBeneath('notes.txt', false, replaceThenOpen)).rejects.toThrow( + 'changed while it was being opened' + ) + }) + + it.runIf(isWindows)('opens an in-root file through a case-variant spelling', async () => { + await writeFile(join(root, 'FILE.txt'), 'inside') + + const file = await openWin32NativeFile( + root.toLowerCase(), + 'file.txt', + await stat(root, { bigint: true }), + false + ) + + expect(await readAll(file)).toBe('inside') + }) + + it.runIf(isWindows)('refuses a case-variant sibling that extends the grant name', () => { + expect(isWithinRoot('C:\\Root', 'c:\\root\\file.txt')).toBe(true) + expect(isWithinRoot('C:\\Root', 'c:\\ROOT2\\file.txt')).toBe(false) + }) + + it('refuses a listing of a directory replaced while it was read', async () => { + await mkdir(join(root, 'docs')) + await writeFile(join(root, 'docs', 'real.txt'), 'real') + const replaceThenList: Win32NativeFileSystem = { + open: (path) => open(path, 'r'), + opendir: async (path) => { + await rename(path, join(root, 'docs-before')) + await mkdir(path) + await writeFile(join(path, 'planted.txt'), 'planted') + return opendir(path) + }, + } + const directory = await openBeneath('docs', true, replaceThenList) + + await expect(list(directory)).rejects.toThrow('changed while it was being read') + }) + + it.each([ + ['an alternate data stream', 'notes.txt:secret'], + ['a reserved device name', 'NUL'], + ['a reserved device name with an extension', 'con.txt'], + ['a trailing dot', 'notes.txt.'], + ['a trailing space', 'notes.txt '], + ])('refuses %s in the requested path', async (_label, name) => { + // POSIX can store these names, so only the segment check stands between them and the open. + if (!isWindows) await writeFile(join(root, name), 'named') + + await expect(openBeneath(name)).rejects.toThrow('within the approved folder') + }) +}) + +describe.each(BACKENDS)('the %s backend contract', (_name, openApproved) => { + async function openDirectory() { + return openApproved(root, '', await stat(root, { bigint: true }), true) + } + + it('lists 1,000 entries and flags truncation once a directory holds 1,001', async () => { + await Promise.all( + Array.from({ length: 1001 }, (_, index) => writeFile(join(root, `${index}.txt`), '')) + ) + + const listing = await list(await openDirectory()) + expect(listing.entries).toHaveLength(1000) + expect(listing.truncated).toBe(true) + + await unlink(join(root, '0.txt')) + const exact = await list(await openDirectory()) + expect(exact.entries).toHaveLength(1000) + expect(exact.truncated).toBe(false) + }) + + it('refuses a directory opened as a file with the regular-file error', async () => { + await mkdir(join(root, 'docs')) + + await expect( + openApproved(root, 'docs', await stat(root, { bigint: true }), false) + ).rejects.toThrow('The approved path is not a regular file or directory.') + }) + + it('refuses a granted folder replaced since the grant', async () => { + const granted = await stat(root, { bigint: true }) + await rename(root, join(base, 'Root-before')) + await mkdir(root) + + await expect(openApproved(root, '', granted, true)).rejects.toThrow( + 'The approved folder changed. Request access again.' + ) + }) +}) diff --git a/apps/desktop/src/main/win32-native-files.ts b/apps/desktop/src/main/win32-native-files.ts new file mode 100644 index 00000000000..a11981ad2d4 --- /dev/null +++ b/apps/desktop/src/main/win32-native-files.ts @@ -0,0 +1,140 @@ +import type { Dir, Dirent } from 'node:fs' +import { type FileHandle, lstat, open, opendir } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import type { LocalFilesystemEntryKind } from '@sim/desktop-bridge' +import { type FileIdentity, fileEntryKind, sameFileIdentity } from '@/main/file-identity' +import { localRealpath } from '@/main/local-realpath' +import type { NativeDirectoryListing, NativeFileHandle } from '@/main/native-directory' +import { isWithinRoot } from '@/main/path-containment' +import { isWin32SafeSegment } from '@/main/win32-path' + +/** The opens a swap has to race. Tests interpose on them to swap a path mid-check. */ +export interface Win32NativeFileSystem { + open: (path: string) => Promise + opendir: (path: string) => Promise +} + +const NODE_FILE_SYSTEM: Win32NativeFileSystem = { open: (path) => open(path, 'r'), opendir } + +/** + * Reparse points classified at once while listing. A OneDrive folder is all placeholders, each of + * which needs its own `lstat`; awaiting them one by one makes a large folder slow to list. + */ +const ENTRY_KIND_CONCURRENCY = 16 + +const OUTSIDE_GRANT = 'The path must stay within the approved folder.' + +function isRequestedKind(info: { isFile(): boolean; isDirectory(): boolean }, directory: boolean) { + return directory ? info.isDirectory() : info.isFile() +} + +/** + * Windows enumeration marks every reparse point as a link, OneDrive placeholders + * included, though those are ordinary files. `lstat` reports only symlinks and + * junctions as links, so it settles the kind without following anything. + */ +async function entryKind(directory: string, entry: Dirent): Promise { + if (!entry.isSymbolicLink()) return fileEntryKind(entry) + try { + return fileEntryKind(await lstat(join(directory, entry.name))) + } catch { + return 'other' + } +} + +/** + * Resolves `path` and requires that it is already canonical and beneath + * `rootReal`. A link or junction anywhere in it, even one pointing back inside + * the grant, is refused, as the POSIX module refuses any symlink component. + * {@link localRealpath} refuses a link to a network path before following it, + * and a path that resolves to a network share. + */ +async function canonicalBeneath(rootReal: string, path: string): Promise { + const real = await localRealpath(path) + if (!isWithinRoot(rootReal, real) || relative(path, real) !== '') throw new Error(OUTSIDE_GRANT) + return real +} + +/** + * The Windows backend for {@link openNativeFile}, on Node fs. Node cannot open + * relative to a descriptor here, so each check resolves a pathname: the root's + * identity, a canonical path with no link component, the target's identity + * before the open matching the opened handle's, and the root and path again + * after it. A directory listing must leave the path naming the opened handle. + * + * This is weaker than the POSIX module, which opens each component with + * `openat(O_NOFOLLOW)` so no link is ever followed. Here the open is by + * pathname: a process already running as the user that swaps an intermediate + * component for a junction or symlink between the checks and the open makes + * the open follow it. The identity check then refuses any handle that is not + * the file that was checked, so the swap cannot read a different file, but the + * open has already reached the link's target, which for a symlink to a share + * means an SMB connection. Closing that needs a native helper that opens each + * component without following reparse points. Written against the host's + * `node:path`, so it also runs on POSIX, where tests exercise it. + */ +export async function openWin32NativeFile( + root: string, + relativePath: string, + identity: FileIdentity, + directory: boolean, + fs: Win32NativeFileSystem = NODE_FILE_SYSTEM +): Promise { + const segments = relativePath === '' ? [] : relativePath.split(sep) + if (!segments.every(isWin32SafeSegment)) throw new Error(OUTSIDE_GRANT) + const assertRootCurrent = async () => { + const info = await lstat(root, { bigint: true }) + if (!info.isDirectory() || !sameFileIdentity(info, identity)) + throw new Error('The approved folder changed. Request access again.') + } + await assertRootCurrent() + const rootReal = await localRealpath(root) + const path = await canonicalBeneath(rootReal, join(root, ...segments)) + const checked = await lstat(path, { bigint: true }) + if (!isRequestedKind(checked, directory)) + throw new Error('The approved path is not a regular file or directory.') + + const handle = await fs.open(path) + try { + const opened = await handle.stat({ bigint: true }) + if (!sameFileIdentity(opened, checked) || !isRequestedKind(opened, directory)) + throw new Error('The local path changed while it was being opened. Try again.') + await canonicalBeneath(rootReal, path) + await assertRootCurrent() + } catch (error) { + await handle.close() + throw error + } + + return { + get fd() { + return handle.fd + }, + stat: () => handle.stat(), + statBigint: () => handle.stat({ bigint: true }), + read: (buffer, offset, length, position) => handle.read(buffer, offset, length, position), + readDirectory: async (limit) => { + const scanned: Dirent[] = [] + let truncated = false + for await (const entry of await fs.opendir(path)) { + if (scanned.length === limit) { + truncated = true + break + } + scanned.push(entry) + } + const entries: NativeDirectoryListing['entries'] = [] + for (let start = 0; start < scanned.length; start += ENTRY_KIND_CONCURRENCY) { + const batch = scanned.slice(start, start + ENTRY_KIND_CONCURRENCY) + const kinds = await Promise.all(batch.map((entry) => entryKind(path, entry))) + batch.forEach((entry, index) => entries.push({ name: entry.name, kind: kinds[index] })) + } + const listed = await lstat(path, { bigint: true }) + if (!sameFileIdentity(listed, checked)) + throw new Error('The local directory changed while it was being read. Try again.') + await canonicalBeneath(rootReal, path) + return { entries, truncated } + }, + close: () => handle.close(), + } +} diff --git a/apps/desktop/src/main/win32-path.test.ts b/apps/desktop/src/main/win32-path.test.ts new file mode 100644 index 00000000000..98fc1e3f223 --- /dev/null +++ b/apps/desktop/src/main/win32-path.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { isWin32SafeSegment, toWin32SafeFileName } from '@/main/win32-path' + +describe('isWin32SafeSegment', () => { + it.each(['CON', 'con', 'Nul.txt', 'COM1', 'lpt9.log', 'CON .txt', 'COM¹'])( + 'refuses the reserved device name %s', + (segment) => { + expect(isWin32SafeSegment(segment)).toBe(false) + } + ) + + it.each(['file.txt:stream', 'ab', 'a"b', 'a|b', 'a?b', 'a*b', 'a\\b', 'a/b', 'a\tb'])( + 'refuses %s, which holds a character NTFS rejects or reads as a stream', + (segment) => { + expect(isWin32SafeSegment(segment)).toBe(false) + } + ) + + it.each(['file.', 'file ', '...'])('refuses %s, which Windows would silently trim', (segment) => { + expect(isWin32SafeSegment(segment)).toBe(false) + }) + + it.each(['notes.txt', 'CONFIG', 'console.log', 'com10', '.env'])('accepts %s', (segment) => { + expect(isWin32SafeSegment(segment)).toBe(true) + }) +}) + +describe('toWin32SafeFileName', () => { + it('replaces characters NTFS rejects', () => { + expect(toWin32SafeFileName('Q3: plan |"final"?*.pdf')).toBe( + 'Q3_ plan _draft___final___.pdf' + ) + }) + + it('drops the trailing dots and spaces Windows would strip', () => { + expect(toWin32SafeFileName('report. . ')).toBe('report') + }) + + it('keeps a reserved device stem from opening the device', () => { + expect(toWin32SafeFileName('nul.txt')).toBe('_nul.txt') + expect(toWin32SafeFileName('AUX')).toBe('_AUX') + }) + + it('leaves an ordinary name alone', () => { + expect(toWin32SafeFileName('Q3 plan.pdf')).toBe('Q3 plan.pdf') + }) + + it('produces a name that passes the segment check', () => { + for (const name of ['CON:x.', 'a*b ', 'lpt1', 'x|y.']) { + const safe = toWin32SafeFileName(name) + expect(isWin32SafeSegment(safe)).toBe(true) + } + }) +}) diff --git a/apps/desktop/src/main/win32-path.ts b/apps/desktop/src/main/win32-path.ts new file mode 100644 index 00000000000..a37f2e87614 --- /dev/null +++ b/apps/desktop/src/main/win32-path.ts @@ -0,0 +1,70 @@ +import { win32 } from 'node:path' + +/** + * Windows treats these stems as devices in any directory and with any + * extension (`NUL.txt` is still NUL). The superscript digits are reserved too. + */ +const RESERVED_DEVICE_STEM = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$)$/i + +/** `:` opens an alternate data stream; the rest are invalid in any NTFS name. */ +const INVALID_NAME_CHARACTERS = /[<>:"/\\|?*\x00-\x1f]/ +const INVALID_NAME_CHARACTERS_GLOBAL = new RegExp(INVALID_NAME_CHARACTERS.source, 'g') + +function isReservedDeviceName(name: string): boolean { + return RESERVED_DEVICE_STEM.test(name.split('.')[0].trimEnd()) +} + +/** + * Whether `segment` names exactly the file it spells as one Windows path + * component. Windows silently strips a trailing dot or space, reads `name:x` + * as a stream of `name`, and opens a reserved device name as the device, so + * each of those reaches something other than the file that was checked. + */ +export function isWin32SafeSegment(segment: string): boolean { + return ( + segment.length > 0 && + !INVALID_NAME_CHARACTERS.test(segment) && + !/[. ]$/.test(segment) && + !isReservedDeviceName(segment) + ) +} + +/** Rewrites an untrusted file name into one Windows stores under exactly that name. */ +export function toWin32SafeFileName(name: string): string { + const replaced = name.replace(INVALID_NAME_CHARACTERS_GLOBAL, '_').replace(/[. ]+$/, '') + return isReservedDeviceName(replaced) ? `_${replaced}` : replaced +} + +const DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/ +const HOME_RELATIVE = /^~[\\/]/ + +const DRIVE_PATH_REQUIRED = 'Use a drive path such as C:\\Users\\name\\file or a ~\\ path.' + +/** + * Resolves a model-supplied Windows path without touching the filesystem. + * + * It runs before any `realpath` or `stat`, because opening a UNC path + * (`\\host\share`) makes Windows authenticate to that host and hands it the + * user's NTLM hash before any consent dialog. Only a drive-absolute path or a + * path under `~` is accepted, so UNC, `\\?\` and `\\.\` device paths, + * drive-relative `C:foo`, and rooted `\foo` (relative to the current drive) + * are all refused. Each segment must also pass {@link isWin32SafeSegment}. + * + * Being lexical, it cannot see a symlink inside the path that targets a share, + * or a drive letter mapped to one; `localRealpath` refuses both before any + * consent dialog. + */ +export function resolveWin32NativePath(value: string, home: string): string { + const homeRelative = value === '~' ? '' : HOME_RELATIVE.test(value) ? value.slice(2) : null + if (homeRelative === null && !DRIVE_ABSOLUTE.test(value)) throw new Error(DRIVE_PATH_REQUIRED) + if (homeRelative !== null && /^[\\/]/.test(homeRelative)) throw new Error(DRIVE_PATH_REQUIRED) + const segments = (homeRelative ?? value.slice(3)).split(/[\\/]/) + for (const segment of segments) { + if (segment === '' || segment === '.' || segment === '..') continue + if (!isWin32SafeSegment(segment)) + throw new Error(`"${segment}" is not a plain Windows file or folder name.`) + } + const resolved = homeRelative === null ? win32.resolve(value) : win32.resolve(home, homeRelative) + if (!DRIVE_ABSOLUTE.test(resolved)) throw new Error(DRIVE_PATH_REQUIRED) + return resolved +} diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index f8c2ca1bd02..65d8ee4b24f 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -3,11 +3,23 @@ import { createSecureWebPreferences } from '@/main/window-preferences' vi.mock('electron', () => import('@/test/electron-mock')) -import { BrowserWindow, dialog, screen, systemPreferences, WebContentsView } from 'electron' +import { + BrowserWindow, + dialog, + nativeTheme, + screen, + systemPreferences, + WebContentsView, +} from 'electron' import { registerAgentWebContents } from '@/main/browser-agent/registry' import type { ConfigStore } from '@/main/config' import type { EventRecorder } from '@/main/observability' -import { createMainWindow, resolvePermission, setupPermissionHandlers } from '@/main/window' +import { + createMainWindow, + handleSecondInstance, + resolvePermission, + setupPermissionHandlers, +} from '@/main/window' const APP = 'https://sim.ai' @@ -142,7 +154,10 @@ describe('createMainWindow', () => { } as never) }) - function createTestWindow(isCommittedRelaunchPending: () => boolean = () => false) { + function createTestWindow( + isCommittedRelaunchPending: () => boolean = () => false, + platform?: NodeJS.Platform + ) { const config = { filePath: '/tmp/settings.json', getOrigin: vi.fn(() => APP), @@ -163,6 +178,7 @@ describe('createMainWindow', () => { isPackaged: false, onClosed: vi.fn(), isCommittedRelaunchPending, + platform, }) const contentHandlers = new Map( vi.mocked(win.webContents.on).mock.calls as unknown as Array< @@ -194,4 +210,69 @@ describe('createMainWindow', () => { handler?.(event as never) expect(event.preventDefault).toHaveBeenCalledOnce() }) + + describe('native theme on Windows', () => { + afterEach(() => { + nativeTheme.themeSource = 'system' + }) + + function reportTheme(platform: NodeJS.Platform) { + const { win } = createTestWindow(undefined, platform) + Object.defineProperty(win.webContents.mainFrame, 'url', { value: `${APP}/workspace` }) + const handler = new Map( + vi.mocked(win.webContents.ipc.on).mock.calls as unknown as Array< + [string, (event: unknown, theme: unknown, preference?: unknown) => void] + > + ).get('shell:app-theme') + if (!handler) throw new Error('shell:app-theme handler not registered') + const event = { sender: win.webContents, senderFrame: win.webContents.mainFrame } + return (theme: string, preference?: string) => handler(event, theme, preference) + } + + it('never pins the OS theme from a resolved theme alone', () => { + // Pinning would also pin prefers-color-scheme and freeze a Sim that follows the system. + reportTheme('win32')('light') + expect(nativeTheme.themeSource).toBe('system') + }) + + it('follows an explicit choice and releases it when Sim follows the system again', () => { + const report = reportTheme('win32') + report('dark', 'dark') + expect(nativeTheme.themeSource).toBe('dark') + report('dark', 'system') + expect(nativeTheme.themeSource).toBe('system') + }) + + it('leaves macOS appearance alone', () => { + reportTheme('darwin')('dark', 'dark') + expect(nativeTheme.themeSource).toBe('system') + }) + }) +}) + +describe('handleSecondInstance', () => { + function deps(platform: NodeJS.Platform) { + const existing = new BrowserWindow() + return { + existing, + ensureMainWindow: vi.fn(() => Promise.resolve(existing)), + createWindow: vi.fn(() => Promise.resolve(new BrowserWindow())), + showWindow: vi.fn(), + platform, + } + } + + it('surfaces the running window on Windows instead of opening another', async () => { + const d = deps('win32') + await handleSecondInstance(d) + expect(d.createWindow).not.toHaveBeenCalled() + expect(d.showWindow).toHaveBeenCalledWith(d.existing) + }) + + it('still opens another window for an explicit extra launch on macOS', async () => { + const d = deps('darwin') + await handleSecondInstance(d) + expect(d.createWindow).toHaveBeenCalledOnce() + expect(d.showWindow).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 095385970f8..196d3045b96 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -191,6 +191,20 @@ export function handleWillPreventUnload( } } +/** + * Points the native caption (and Electron-drawn menus) at Sim's own theme + * choice on Windows. Only the user's app-wide *preference* (`sim-theme`) may + * drive this, never the resolved theme or a page's own theme: `themeSource` is + * process-wide and also pins `prefers-color-scheme`, so a forced-light page in + * one window would repaint every window, and pinning a resolved "light" would + * freeze a Sim set to follow the system. A sender that reports no preference + * leaves the OS default in place. + */ +function syncNativeThemeSource(preference: unknown): void { + if (preference !== 'system' && preference !== 'dark' && preference !== 'light') return + if (nativeTheme.themeSource !== preference) nativeTheme.themeSource = preference +} + export interface CreateMainWindowDeps { config: ConfigStore events: EventRecorder @@ -210,6 +224,11 @@ export interface CreateMainWindowDeps { restorePosition?: boolean /** Injectable for platform-specific window behavior tests. */ platform?: NodeJS.Platform + /** + * Window icon for Windows runs that have no packaged executable icon to + * inherit; packaged builds omit it. + */ + icon?: string } /** @@ -247,6 +266,11 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { titleBarOverlay: true, } : {}), + // Windows keeps its native frame; the menu bar stays out of the page's + // space until Alt reveals it. + ...(platform === 'win32' + ? { autoHideMenuBar: true, ...(deps.icon ? { icon: deps.icon } : {}) } + : {}), show: false, backgroundColor: backgroundColorFor( deps.config.get('themeBackground'), @@ -260,7 +284,7 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { }) attachShellTheme(win) - win.webContents.ipc.on('shell:app-theme', (event, theme: unknown) => { + win.webContents.ipc.on('shell:app-theme', (event, theme: unknown, preference: unknown) => { if ( event.sender !== win.webContents || event.senderFrame !== win.webContents.mainFrame || @@ -270,6 +294,7 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { return deps.config.set('themeBackground', theme) setShellTheme(theme) + if (platform === 'win32') syncNativeThemeSource(preference) }) let boundsTimer: NodeJS.Timeout | undefined @@ -419,3 +444,27 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { return win } + +export interface SecondInstanceDeps { + /** The existing full Sim window, creating one only when none is open. */ + ensureMainWindow: () => Promise + createWindow: () => Promise + showWindow: (win: BrowserWindow) => void + /** Injectable for platform-specific lifecycle tests. */ + platform?: NodeJS.Platform +} + +/** + * Answers a second OS launch forwarded by the single-instance lock. On + * Windows every Start-menu, taskbar, or shortcut launch while Sim runs + * arrives here, so it must surface the window the user already has rather + * than stack a new one per click. macOS only sees this for an explicit extra + * launch (`open -n`), which keeps meaning "another window". + */ +export async function handleSecondInstance(deps: SecondInstanceDeps): Promise { + if ((deps.platform ?? process.platform) === 'darwin') { + await deps.createWindow() + return + } + deps.showWindow(await deps.ensureMainWindow()) +} diff --git a/apps/desktop/src/main/windows.test.ts b/apps/desktop/src/main/windows.test.ts index 972026ec529..05dc504c7cd 100644 --- a/apps/desktop/src/main/windows.test.ts +++ b/apps/desktop/src/main/windows.test.ts @@ -29,27 +29,28 @@ describe('attachWindowOpenPolicy', () => { vi.mocked(shell.openExternal).mockClear() }) - function setup(isCommittedRelaunchPending: () => boolean = () => false) { + function setup(platform: NodeJS.Platform = 'darwin') { const contents = makeContents() const openAppWindow = vi.fn() attachWindowOpenPolicy(contents as unknown as WebContents, { appOrigin: () => APP, openAppWindow, allowHttpLocalhost: false, - isCommittedRelaunchPending, + isCommittedRelaunchPending: () => false, + platform, }) return { contents, openAppWindow } } - it('allows the MCP OAuth popup', () => { - const { contents } = setup() + it.each(['darwin', 'win32'] as const)('allows the MCP OAuth popup isolated on %s', (platform) => { + const { contents } = setup(platform) const result = contents.handler?.({ url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1', }) expect(result).toEqual({ action: 'allow', - overrideBrowserWindowOptions: { + overrideBrowserWindowOptions: expect.objectContaining({ webPreferences: expect.objectContaining({ preload: undefined, additionalArguments: [], @@ -59,7 +60,7 @@ describe('attachWindowOpenPolicy', () => { webSecurity: true, webviewTag: false, }), - }, + }), }) }) diff --git a/apps/desktop/src/main/windows.ts b/apps/desktop/src/main/windows.ts index fcb9c41f115..8d648b95320 100644 --- a/apps/desktop/src/main/windows.ts +++ b/apps/desktop/src/main/windows.ts @@ -29,6 +29,13 @@ const ISOLATED_CHILD_WINDOW_OPTIONS = { }, } satisfies BrowserWindowConstructorOptions +/** Popups keep the native Windows frame but not the app's menu bar, which Alt still reveals. */ +function childWindowOptions(platform: NodeJS.Platform): BrowserWindowConstructorOptions { + return platform === 'win32' + ? { ...ISOLATED_CHILD_WINDOW_OPTIONS, autoHideMenuBar: true } + : ISOLATED_CHILD_WINDOW_OPTIONS +} + /** * Marks a WebContents as a guarded popup child (MCP OAuth, blank-then-assign) * so the navigation classifier can apply the more permissive popup policy. @@ -46,6 +53,8 @@ export interface WindowPolicyDeps { openAppWindow: (url: string) => void allowHttpLocalhost: boolean isCommittedRelaunchPending: () => boolean + /** Injectable for platform-specific window behavior tests. */ + platform?: NodeJS.Platform } /** @@ -63,7 +72,7 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy case 'popup-blank': return { action: 'allow', - overrideBrowserWindowOptions: ISOLATED_CHILD_WINDOW_OPTIONS, + overrideBrowserWindowOptions: childWindowOptions(deps.platform ?? process.platform), } case 'popup-internal': { deps.openAppWindow(details.url) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 7f06f2b5180..272dbf4a261 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -37,6 +37,7 @@ import type { DesktopNotificationPayload, DesktopOAuthConnectResult, DesktopOAuthConnectScope, + DesktopPlatform, DesktopPreferenceKey, DesktopPreferences, DesktopServerChangeResult, @@ -61,6 +62,7 @@ import { } from '@sim/terminal-protocol' import { contextBridge, ipcRenderer } from 'electron' import { exposeShellTheme, observeAppTheme } from '@/preload/shell-theme' +import { desktopFeatures } from '@/shared/platform-features' exposeShellTheme() observeAppTheme() @@ -117,13 +119,21 @@ function shellVersion(): string { return version || '0.0.0' } +/** Any other platform reports as Linux. */ +function bridgePlatform(): DesktopPlatform { + return process.platform === 'darwin' || process.platform === 'win32' ? process.platform : 'linux' +} + +const features = desktopFeatures(process.platform) + /** * The narrow bridge exposed to pages. Every channel is validated and gated in * the main process — nothing here grants page code any privilege by itself. */ const api: SimDesktopApi = { version: shellVersion(), - ...(process.platform === 'darwin' + platform: bridgePlatform(), + ...(features.computerUse ? { computerUse: { getActivity: () => ipcRenderer.invoke('computer-use:get-activity'), @@ -419,11 +429,7 @@ const api: SimDesktopApi = { } }, }, - // Omitted entirely off macOS, so the web app's feature detection reflects - // whether an import can actually run rather than only whether the shell is - // new enough. Chrome's App-Bound Encryption on Windows is deliberately not - // worked around. - ...(process.platform === 'darwin' + ...(features.terminalThemes ? { terminalThemes: { listProfiles: (): Promise => @@ -431,6 +437,14 @@ const api: SimDesktopApi = { selectProfile: (profileId: string): Promise => ipcRenderer.invoke('terminal-themes:select-profile', profileId), }, + } + : {}), + // Omitted entirely where no importer runs, so the web app's feature detection + // reflects whether an import can actually run rather than only whether the + // shell is new enough. Chrome's App-Bound Encryption on Windows is + // deliberately not worked around. + ...(features.browserImport + ? { browserImport: { listChromeProfiles: (): Promise => ipcRenderer.invoke('browser-import:list-profiles'), @@ -469,12 +483,21 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-credentials:forget-all'), reveal: (id: string): Promise => ipcRenderer.invoke('browser-credentials:reveal', id), - copy: (id: string): Promise => ipcRenderer.invoke('browser-credentials:copy', id), - importFromChrome: ( - profileId?: string, - policy?: BrowserCredentialConflictPolicy - ): Promise => - ipcRenderer.invoke('browser-credentials:import', profileId, policy), + ...(features.passwordCopy + ? { + copy: (id: string): Promise => + ipcRenderer.invoke('browser-credentials:copy', id), + } + : {}), + ...(features.browserImport + ? { + importFromChrome: ( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise => + ipcRenderer.invoke('browser-credentials:import', profileId, policy), + } + : {}), showChooser: (anchor: { x: number; y: number }, scopeId: string): Promise => ipcRenderer.invoke('browser-credentials:show-chooser', anchor, scopeId), listFillOptions: (scopeId: string): Promise => diff --git a/apps/desktop/src/preload/shell-theme.ts b/apps/desktop/src/preload/shell-theme.ts index d12f010555b..369888c1434 100644 --- a/apps/desktop/src/preload/shell-theme.ts +++ b/apps/desktop/src/preload/shell-theme.ts @@ -14,21 +14,41 @@ export function exposeShellTheme(): void { contextBridge.exposeInMainWorld('simShellTheme', api) } -/** Reports the actual app theme, including changes after hydration or OS theme changes. */ +/** + * The user's app-wide theme choice (the next-themes `sim-theme` key), or `system`. A page's forced + * theme (`/login`, `/chat`) and the landing pages' own `sim-landing-theme` are deliberately ignored: + * on Windows this value drives `nativeTheme.themeSource`, which is process-wide, so a per-page theme + * would repaint every other window's caption and `prefers-color-scheme`. + */ +function themePreference(): string { + try { + return localStorage.getItem('sim-theme') ?? 'system' + } catch { + return 'system' + } +} + +/** + * Reports the actual app theme and the user's preference behind it, including + * changes after hydration or OS theme changes. + */ export function observeAppTheme(): void { window.addEventListener('DOMContentLoaded', () => { if (!['https:', 'http:'].includes(location.protocol)) return - let previous: ShellTheme | undefined + let previous: string | undefined const report = () => { const root = document.documentElement - const theme = root.classList.contains('dark') + const theme: ShellTheme | undefined = root.classList.contains('dark') ? 'dark' : root.classList.contains('light') ? 'light' : undefined - if (!theme || theme === previous) return - previous = theme - ipcRenderer.send('shell:app-theme', theme) + if (!theme) return + const preference = themePreference() + const reported = `${theme}:${preference}` + if (reported === previous) return + previous = reported + ipcRenderer.send('shell:app-theme', theme, preference) } new MutationObserver(report).observe(document.documentElement, { attributes: true, diff --git a/apps/desktop/src/renderer/offline/index.tsx b/apps/desktop/src/renderer/offline/index.tsx index 2b507f46e3b..476baca7993 100644 --- a/apps/desktop/src/renderer/offline/index.tsx +++ b/apps/desktop/src/renderer/offline/index.tsx @@ -117,7 +117,7 @@ function OfflinePage({ isSimCloud }: OfflinePageProps) { const container = document.getElementById('root') if (!container) throw new Error('Offline page root is missing') { - observeDesktopTitleBar(document.documentElement, navigator.userAgent, bridge) + observeDesktopTitleBar(document.documentElement, bridge) const root = createRoot(container) root.render() void bridge?.server diff --git a/apps/desktop/src/shared/channel-identity.ts b/apps/desktop/src/shared/channel-identity.ts new file mode 100644 index 00000000000..a2e23d40f97 --- /dev/null +++ b/apps/desktop/src/shared/channel-identity.ts @@ -0,0 +1,78 @@ +/** + * Each desktop channel's identity, in the one table both the packager and the running app read: + * `scripts/channels.ts` stamps it into the bundle and installer, and `src/main` claims it at + * startup (`app.setName`, which decides the userData directory and single-instance lock, and the + * Windows AppUserModelID). When the two disagree, the app on disk and the running app disagree + * about which app they are. + */ + +/** + * The environment a build is keyed to. Each is a separate app, so one developer can keep a prod, + * staging, dev, and localhost install side by side, each with its own settings, sessions, and + * update feed. + */ +export type DesktopChannel = 'prod' | 'staging' | 'dev' | 'local' + +export interface DesktopChannelIdentity { + /** + * Display and bundle name; also the userData directory name. Prod keeps the plain name every + * existing install already has, so its userData never moves. + */ + name: string + /** + * The installer's app ID. On Windows it is also the AppUserModelID: the running process must + * claim the ID the installer stamped on its Start-menu shortcut, or toasts lose their + * attribution and taskbar pins group under a separate icon. + */ + appId: string + /** + * The 512px app icon under `static/`, for runs with no packaged icon to inherit and for the + * Windows tray; the Windows icon is generated from it (`scripts/generate-windows-icons.ts`). + */ + dockIcon: string + /** Native Icon Composer source copied into the packager's generated path. */ + icon: string + /** Multi-size Windows icon copied into the packager's generated path. */ + windowsIcon: string + /** + * Artifact filename stem, and the per-channel scratch directory name. Space-free for the same + * reason electron-builder.yml's artifactName is: GitHub rewrites asset names containing spaces, + * which desyncs the electron-updater manifest from the uploaded files. + */ + slug: string +} + +export const CHANNEL_IDENTITIES: Record = { + prod: { + name: 'Sim', + appId: 'ai.sim.desktop', + dockIcon: 'dock-icon.png', + icon: 'build/icon.icon', + windowsIcon: 'build/icon.ico', + slug: 'sim', + }, + staging: { + name: 'Sim Staging', + appId: 'ai.sim.desktop.staging', + dockIcon: 'dock-icon-staging.png', + icon: 'build/icon-staging.icon', + windowsIcon: 'build/icon-staging.ico', + slug: 'sim-staging', + }, + dev: { + name: 'Sim Dev', + appId: 'ai.sim.desktop.dev', + dockIcon: 'dock-icon-dev.png', + icon: 'build/icon-dev.icon', + windowsIcon: 'build/icon-dev.ico', + slug: 'sim-dev', + }, + local: { + name: 'Sim Local', + appId: 'ai.sim.desktop.local', + dockIcon: 'dock-icon-local.png', + icon: 'build/icon-local.icon', + windowsIcon: 'build/icon-local.ico', + slug: 'sim-local', + }, +} diff --git a/apps/desktop/src/shared/platform-features.ts b/apps/desktop/src/shared/platform-features.ts new file mode 100644 index 00000000000..9b074ddc633 --- /dev/null +++ b/apps/desktop/src/shared/platform-features.ts @@ -0,0 +1,64 @@ +/** + * What the shell ships on each operating system. The preload (which bridge + * surfaces exist) and the IPC channel specs (`platformFeature`, which channels + * are registered) both read this one table, so a surface cannot be exposed on + * one side and refused on the other. Main also reads it for computer use, the + * Help menu search field, and tmux. + * + * Computer use additionally needs a recent macOS; that version check stays + * with the service that knows the OS release. + */ +export interface DesktopFeatures { + computerUse: boolean + /** The native macOS Help menu search field. */ + helpSearch: boolean + /** Terminal.app and iTerm2 color profiles. */ + terminalThemes: boolean + /** Chrome cookie and password import. */ + browserImport: boolean + /** + * Copying a saved password to the system clipboard. Off on Windows, where + * Clipboard History and the cloud clipboard keep the text after Sim clears it. + */ + passwordCopy: boolean + /** + * Attaching the agent to tmux running in a terminal. Off on Windows: attaching matches tmux + * clients to the shell through POSIX ttys and `ps`, and an MSYS or Cygwin tmux on PATH lives in + * a process and path world the Windows shell is not part of. + */ + tmux: boolean +} + +const MAC_FEATURES: DesktopFeatures = { + computerUse: true, + helpSearch: true, + terminalThemes: true, + browserImport: true, + passwordCopy: true, + tmux: true, +} + +const WINDOWS_FEATURES: DesktopFeatures = { + computerUse: false, + helpSearch: false, + terminalThemes: false, + browserImport: false, + passwordCopy: false, + tmux: false, +} + +/** Linux is unpackaged: macOS's surfaces minus the macOS-only ones. */ +const LINUX_FEATURES: DesktopFeatures = { + computerUse: false, + helpSearch: false, + terminalThemes: false, + browserImport: false, + passwordCopy: true, + tmux: true, +} + +/** The desktop surfaces available on `platform`. */ +export function desktopFeatures(platform: NodeJS.Platform): DesktopFeatures { + if (platform === 'darwin') return MAC_FEATURES + return platform === 'win32' ? WINDOWS_FEATURES : LINUX_FEATURES +} diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 105aa55f117..f67710787b9 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -80,6 +80,7 @@ export const systemPreferences = { export const nativeTheme = { shouldUseDarkColors: false, + themeSource: 'system' as 'system' | 'light' | 'dark', on: vi.fn(), } @@ -328,6 +329,7 @@ export class BrowserWindow { setTitle = vi.fn() setVisibleOnAllWorkspaces = vi.fn() setAlwaysOnTop = vi.fn() + removeMenu = vi.fn() getSize = vi.fn(() => [1180, 850]) getContentSize = vi.fn(() => [1180, 850]) contentView = { diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index d23c4d7e613..5cc7eca9c66 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -8,7 +8,19 @@ export default mergeConfig( test: { setupFiles: ['src/test/setup.ts'], include: ['src/**/*.test.ts'], - exclude: ['**/e2e/**'], + // These suites drive subsystems Windows builds do not ship (tmux, the macOS computer-use + // helper, the Chromium profile readers behind browser import); see + // src/shared/platform-features.ts. + exclude: [ + '**/e2e/**', + ...(process.platform === 'win32' + ? [ + 'src/main/terminal/tmux.test.ts', + 'src/main/computer-use/native-client.test.ts', + 'src/main/browser-import/chromium-*.test.ts', + ] + : []), + ], pool: 'threads', testTimeout: 10000, }, diff --git a/apps/sim/app/_shell/desktop-title-bar.tsx b/apps/sim/app/_shell/desktop-title-bar.tsx index 79430d47a7d..1a55b41a9ab 100644 --- a/apps/sim/app/_shell/desktop-title-bar.tsx +++ b/apps/sim/app/_shell/desktop-title-bar.tsx @@ -30,7 +30,7 @@ export function DesktopTitleBarLane() { export function DesktopTitleBarController() { useEffect(() => { const bridge = getDesktopBridge() - return observeDesktopTitleBar(document.documentElement, navigator.userAgent, bridge) + return observeDesktopTitleBar(document.documentElement, bridge) }, []) return null diff --git a/apps/sim/app/_shell/desktop-update-gate.tsx b/apps/sim/app/_shell/desktop-update-gate.tsx index 27f47c3f244..dd8cb2600ea 100644 --- a/apps/sim/app/_shell/desktop-update-gate.tsx +++ b/apps/sim/app/_shell/desktop-update-gate.tsx @@ -3,7 +3,13 @@ import { useEffect, useState } from 'react' import type { DesktopUpdateState } from '@sim/desktop-bridge' import { Chip, useNativeSurfaceOcclusionReady } from '@sim/emcn' -import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' +import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' +import { + getDesktopBridge, + getDesktopPlatform, + getDesktopShellVersion, + getDesktopUpdates, +} from '@/lib/desktop' import { isShellOutdated } from '@/lib/desktop/min-version' import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' @@ -19,9 +25,10 @@ function manualDownloadUrl(): string { const origin = window.location.origin // openExternal only accepts https, so an http self-hosted origin cannot // serve the redirect to the system browser. - return origin.startsWith('https://') - ? `${origin}${DOWNLOAD_REDIRECT_PATH}` - : DOWNLOAD_FALLBACK_URL + if (!origin.startsWith('https://')) return DOWNLOAD_FALLBACK_URL + // Shells that cannot report their platform predate Windows support: macOS. + const platform: DesktopUpdatePlatform = getDesktopPlatform() === 'win32' ? 'windows' : 'mac' + return `${origin}${DOWNLOAD_REDIRECT_PATH}?platform=${platform}` } interface GateAction { diff --git a/apps/sim/app/api/desktop/update/download/route.test.ts b/apps/sim/app/api/desktop/update/download/route.test.ts index 0246374ab6d..7f10c965be4 100644 --- a/apps/sim/app/api/desktop/update/download/route.test.ts +++ b/apps/sim/app/api/desktop/update/download/route.test.ts @@ -6,6 +6,7 @@ import { DESKTOP_STABLE_RELEASE_REPOSITORY, MANIFEST_ASSET_NAME, releasesApiUrl, + WINDOWS_MANIFEST_ASSET_NAME, } from '@/lib/desktop/update-feed' import { GET } from '@/app/api/desktop/update/download/route' @@ -37,8 +38,24 @@ function manifest(version: string) { return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal.zip`].join('\n') } -async function getDownload(): Promise { - return GET(new NextRequest('https://www.sim.ai/api/desktop/update/download'), undefined) +async function getDownload(query = ''): Promise { + return GET(new NextRequest(`https://www.sim.ai/api/desktop/update/download${query}`), undefined) +} + +function withWindowsBuild(macRelease: ReturnType) { + const version = macRelease.tag_name.replace(/^v/, '') + const base = macRelease.assets[0].browser_download_url.replace(/\/[^/]+$/, '') + return { + ...macRelease, + assets: [ + ...macRelease.assets, + { + name: WINDOWS_MANIFEST_ASSET_NAME, + browser_download_url: `${base}/${WINDOWS_MANIFEST_ASSET_NAME}`, + }, + { name: `Sim-${version}-x64.exe`, browser_download_url: `${base}/Sim-${version}-x64.exe` }, + ], + } } describe('desktop update download route', () => { @@ -53,9 +70,13 @@ describe('desktop update download route', () => { const candidate = releases.find((release) => release.assets.some((asset) => asset.browser_download_url === url) ) - return candidate - ? new Response(manifest(candidate.tag_name.replace(/^v/, ''))) - : new Response(null, { status: 404 }) + if (!candidate) return new Response(null, { status: 404 }) + const version = candidate.tag_name.replace(/^v/, '') + return new Response( + url.endsWith(`/${WINDOWS_MANIFEST_ASSET_NAME}`) + ? [`version: ${version}`, 'files:', ` - url: Sim-${version}-x64.exe`].join('\n') + : manifest(version) + ) }) } @@ -125,4 +146,40 @@ describe('desktop update download route', () => { expect(response.status).toBe(502) expect(await response.json()).toMatchObject({ error: 'Release feed unavailable' }) }) + it('redirects a Windows shell to the x64 installer of the newest Windows release', async () => { + mockReleases([ + release('v1.4.0', DESKTOP_STABLE_RELEASE_REPOSITORY), + withWindowsBuild(release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY)), + ]) + + const response = await getDownload('?platform=windows') + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe( + `https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v1.3.0/Sim-1.3.0-x64.exe` + ) + }) + + it('keeps macOS callers on the dmg when a release also ships Windows', async () => { + mockReleases([withWindowsBuild(release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY))]) + + const response = await getDownload() + + expect(response.headers.get('location')).toContain('Sim-1.3.0-universal.dmg') + }) + + it('reports no Windows release instead of redirecting to a macOS installer', async () => { + mockReleases([release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY)]) + + const response = await getDownload('?platform=windows') + + expect(response.status).toBe(404) + }) + + it('rejects an unknown platform', async () => { + const response = await getDownload('?platform=linux') + + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/desktop/update/download/route.ts b/apps/sim/app/api/desktop/update/download/route.ts index f48124a11cd..02645b066e3 100644 --- a/apps/sim/app/api/desktop/update/download/route.ts +++ b/apps/sim/app/api/desktop/update/download/route.ts @@ -1,25 +1,17 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { downloadDesktopUpdateContract } from '@/lib/api/contracts/desktop-update' +import { parseRequest } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - channelForDeploymentEnvironment, - type DesktopReleaseCandidate, - releaseRepositoryForChannel, - releasesApiUrl, - resolveLatestRelease, - resolveReleaseAssets, + DESKTOP_FEED_REVALIDATE_SECONDS, + resolveDeploymentRelease, } from '@/lib/desktop/update-feed' - -const logger = createLogger('DesktopUpdateDownloadAPI') - -/** Matches the manifest feed so both paths resolve the same release. */ -const REVALIDATE_SECONDS = 300 +import { feedErrorResponse } from '@/app/api/desktop/update/manifest-feed' /** - * Redirects to the installer for the newest release of this deployment's - * channel (see `lib/desktop/update-feed.ts`). + * Redirects to the platform's installer for the newest release of this + * deployment's channel (see `lib/desktop/update-feed.ts`). * * This is the manual escape hatch behind the blocking update gate: shells too * old to expose the updater bridge send the user here instead of @@ -31,73 +23,18 @@ const REVALIDATE_SECONDS = 300 * Public by the same reasoning as the manifest feed: it only points at public * GitHub release assets. */ -export const GET = withRouteHandler(async (_request: NextRequest): Promise => { - const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT) - const releaseRepository = releaseRepositoryForChannel(channel) - - const githubToken = env.GITHUB_TOKEN - const resolved = await resolveLatestRelease( - channel, - async (page) => { - try { - const response = await fetch(releasesApiUrl(releaseRepository, page), { - headers: { - accept: 'application/vnd.github+json', - ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), - }, - next: { revalidate: REVALIDATE_SECONDS }, - }) - if (!response.ok) { - logger.error('GitHub releases lookup failed', { - status: response.status, - page, - channel, - releaseRepository, - }) - return null - } - return (await response.json()) as DesktopReleaseCandidate[] - } catch (error) { - logger.error('GitHub releases response could not be read', { - message: getErrorMessage(error), - page, - channel, - releaseRepository, - }) - return null - } - }, - async (release) => { - const assets = await resolveReleaseAssets(release, releaseRepository, (url) => - fetch(url, { - next: { revalidate: REVALIDATE_SECONDS }, - }) - ) - if (!assets) { - logger.warn('Skipping incomplete or invalid desktop release', { - tag: release.tag_name, - channel, - }) - } - return assets?.installer ?? null - } - ) - if ('error' in resolved) { - return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) - } +export const GET = withRouteHandler(async (request: NextRequest): Promise => { + const parsed = await parseRequest(downloadDesktopUpdateContract, request, {}) + if (!parsed.success) return parsed.response - if (!resolved.release) { - if (resolved.rejectedCandidates) { - return NextResponse.json({ error: 'Release installer unavailable' }, { status: 502 }) - } - return NextResponse.json( - { error: `No desktop release for channel ${channel}` }, - { status: 404 } - ) - } - - return NextResponse.redirect(resolved.value.browser_download_url, { + const resolved = await resolveDeploymentRelease({ + environment: env.APPCONFIG_ENVIRONMENT, + githubToken: env.GITHUB_TOKEN, + platform: parsed.data.query.platform, + }) + if (resolved.status !== 'release') return feedErrorResponse(resolved, 'installer') + return NextResponse.redirect(resolved.assets.installer.browser_download_url, { status: 302, - headers: { 'cache-control': `public, max-age=${REVALIDATE_SECONDS}` }, + headers: { 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}` }, }) }) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts index dafe66d433f..62798023d55 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts @@ -1,117 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - channelForDeploymentEnvironment, - type DesktopReleaseCandidate, - releaseRepositoryForChannel, - releasesApiUrl, - resolveLatestRelease, - resolveReleaseAssets, -} from '@/lib/desktop/update-feed' +import { serveDesktopUpdateManifest } from '@/app/api/desktop/update/manifest-feed' -const logger = createLogger('DesktopUpdateFeedAPI') - -/** - * How long a resolved feed may be stale. A fresh release becomes visible to - * shells within this window (their own check cadence is hours anyway). - */ -const REVALIDATE_SECONDS = 300 -const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' - -/** - * The per-environment desktop update feed (see `lib/desktop/update-feed.ts`). - * - * electron-updater's generic provider on installed shells requests this - * exact path from the origin the shell is pointed at. The route is public by - * design: the updater's HTTP client carries no session, and the response - * only describes public GitHub release artifacts. - */ -export const GET = withRouteHandler(async (_request: NextRequest): Promise => { - /** - * Hosted deployments inject APPCONFIG_ENVIRONMENT independently at runtime, - * so it stays correct when the same image is promoted across environments. - * Request host headers are intentionally excluded: this public route must not - * let a caller choose which app-identity release the feed serves. - */ - const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT) - const releaseRepository = releaseRepositoryForChannel(channel) - - // A token raises the GitHub API quota from 60/h per NAT IP to 5000/h. - // Optional: the repo is public, so the feed works without one. - const githubToken = env.GITHUB_TOKEN - const resolved = await resolveLatestRelease( - channel, - async (page) => { - try { - const response = await fetch(releasesApiUrl(releaseRepository, page), { - headers: { - accept: 'application/vnd.github+json', - ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), - }, - next: { revalidate: REVALIDATE_SECONDS }, - }) - if (!response.ok) { - logger.error('GitHub releases lookup failed', { - status: response.status, - page, - channel, - releaseRepository, - }) - return null - } - return (await response.json()) as DesktopReleaseCandidate[] - } catch (error) { - logger.error('GitHub releases response could not be read', { - message: getErrorMessage(error), - page, - channel, - releaseRepository, - }) - return null - } - }, - async (release) => { - const assets = await resolveReleaseAssets(release, releaseRepository, (url) => - fetch(url, { - next: { revalidate: REVALIDATE_SECONDS }, - }) - ) - if (!assets) { - logger.warn('Skipping incomplete or invalid desktop release', { - tag: release.tag_name, - channel, - }) - } - return assets?.manifest ?? null - } - ) - if ('error' in resolved) { - return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) - } - - const release = resolved.release - if (!release) { - if (resolved.rejectedCandidates) { - return NextResponse.json({ error: 'Release manifest unavailable' }, { status: 502 }) - } - return NextResponse.json( - { error: `No desktop release for channel ${channel}` }, - { - status: 404, - headers: { [FEED_STATUS_HEADER]: 'no-release' }, - } - ) - } - - return new NextResponse(resolved.value, { - status: 200, - headers: { - 'content-type': 'text/yaml; charset=utf-8', - 'cache-control': `public, max-age=${REVALIDATE_SECONDS}`, - [FEED_STATUS_HEADER]: 'release', - }, - }) -}) +/** The macOS update manifest (see `manifest-feed.ts`). */ +export const GET = withRouteHandler( + async (): Promise => serveDesktopUpdateManifest('mac') +) diff --git a/apps/sim/app/api/desktop/update/latest.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest.yml/route.test.ts new file mode 100644 index 00000000000..a4154db6c25 --- /dev/null +++ b/apps/sim/app/api/desktop/update/latest.yml/route.test.ts @@ -0,0 +1,120 @@ +import { setEnv } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + DESKTOP_STABLE_RELEASE_REPOSITORY, + MANIFEST_ASSET_NAME, + releasesApiUrl, + WINDOWS_MANIFEST_ASSET_NAME, +} from '@/lib/desktop/update-feed' +import { GET } from '@/app/api/desktop/update/latest.yml/route' + +const STABLE_RELEASES_URL = releasesApiUrl(DESKTOP_STABLE_RELEASE_REPOSITORY, 1) +const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' + +function asset(tag: string, name: string) { + return { name, browser_download_url: `https://downloads.example/${tag}/${name}` } +} + +/** A release carrying the macOS build, plus the Windows build when `windows` is set. */ +function release(tag: string, options?: { windows?: boolean }) { + const version = tag.replace(/^v/, '') + return { + tag_name: tag, + draft: false, + prerelease: tag.includes('-'), + assets: [ + asset(tag, MANIFEST_ASSET_NAME), + asset(tag, `Sim-${version}-universal.zip`), + asset(tag, `Sim-${version}-universal.dmg`), + ...(options?.windows + ? [asset(tag, WINDOWS_MANIFEST_ASSET_NAME), asset(tag, `Sim-${version}-x64.exe`)] + : []), + ], + } +} + +function windowsManifest(version: string, artifact = `Sim-${version}-x64.exe`) { + return [`version: ${version}`, 'files:', ` - url: ${artifact}`, `path: ${artifact}`].join('\n') +} + +function macManifest(version: string) { + return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal.zip`].join('\n') +} + +async function getFeed(): Promise { + return GET(new NextRequest('https://www.sim.ai/api/desktop/update/latest.yml'), undefined) +} + +describe('desktop Windows update manifest route', () => { + const fetchMock = vi.fn() + + function mockFeed( + releases: ReturnType[], + manifests: Record + ): void { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input) + if (url === STABLE_RELEASES_URL) return Response.json(releases) + const body = manifests[url] + return body === undefined ? new Response(null, { status: 404 }) : new Response(body) + }) + } + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + setEnv({ APPCONFIG_ENVIRONMENT: undefined }) + }) + + it('serves the Windows manifest, never the macOS one', async () => { + mockFeed([release('v1.1.0', { windows: true })], { + [`https://downloads.example/v1.1.0/${WINDOWS_MANIFEST_ASSET_NAME}`]: windowsManifest('1.1.0'), + [`https://downloads.example/v1.1.0/${MANIFEST_ASSET_NAME}`]: macManifest('1.1.0'), + }) + + const response = await getFeed() + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') + expect(body).toContain( + ` - url: https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v1.1.0/Sim-1.1.0-x64.exe` + ) + expect(body).not.toContain('universal') + }) + + it('passes over a newer release whose Windows build was never published', async () => { + mockFeed([release('v1.2.0'), release('v1.1.0', { windows: true })], { + [`https://downloads.example/v1.1.0/${WINDOWS_MANIFEST_ASSET_NAME}`]: windowsManifest('1.1.0'), + }) + + const response = await getFeed() + + expect(response.status).toBe(200) + expect(await response.text()).toContain('version: 1.1.0') + }) + + it('reports no release, not a broken feed, before any Windows build ships', async () => { + mockFeed([release('v1.2.0'), release('v1.1.0')], {}) + + const response = await getFeed() + + expect(response.status).toBe(404) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('no-release') + }) + + it('rejects a Windows manifest that points at macOS artifacts', async () => { + mockFeed([release('v1.1.0', { windows: true })], { + [`https://downloads.example/v1.1.0/${WINDOWS_MANIFEST_ASSET_NAME}`]: windowsManifest( + '1.1.0', + 'Sim-1.1.0-universal.zip' + ), + }) + + const response = await getFeed() + + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ error: 'Release manifest unavailable' }) + }) +}) diff --git a/apps/sim/app/api/desktop/update/latest.yml/route.ts b/apps/sim/app/api/desktop/update/latest.yml/route.ts new file mode 100644 index 00000000000..f6eace3109b --- /dev/null +++ b/apps/sim/app/api/desktop/update/latest.yml/route.ts @@ -0,0 +1,7 @@ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { serveDesktopUpdateManifest } from '@/app/api/desktop/update/manifest-feed' + +/** The Windows update manifest electron-updater's NSIS updater requests (see `manifest-feed.ts`). */ +export const GET = withRouteHandler( + async (): Promise => serveDesktopUpdateManifest('windows') +) diff --git a/apps/sim/app/api/desktop/update/manifest-feed.ts b/apps/sim/app/api/desktop/update/manifest-feed.ts new file mode 100644 index 00000000000..788010d3e26 --- /dev/null +++ b/apps/sim/app/api/desktop/update/manifest-feed.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' +import { env } from '@/lib/core/config/env' +import { + DESKTOP_FEED_REVALIDATE_SECONDS, + type DesktopFeedResolution, + resolveDeploymentRelease, +} from '@/lib/desktop/update-feed' + +const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' + +/** + * Serves a platform's electron-updater manifest for this deployment's channel + * (see `lib/desktop/update-feed.ts`), shared by `latest-mac.yml` and + * `latest.yml`. + * + * electron-updater's generic provider on installed shells requests these exact + * paths from the origin the shell is pointed at. The routes are public by + * design: the updater's HTTP client carries no session, and the response only + * describes public GitHub release artifacts. + * + * A 404 tagged `no-release` tells the shell the channel has nothing for its + * platform yet; a 502 means releases exist but none can be offered. + */ +export async function serveDesktopUpdateManifest( + platform: DesktopUpdatePlatform +): Promise { + // Hosted deployments inject APPCONFIG_ENVIRONMENT independently at runtime, + // so it stays correct when the same image is promoted across environments. + const resolved = await resolveDeploymentRelease({ + environment: env.APPCONFIG_ENVIRONMENT, + githubToken: env.GITHUB_TOKEN, + platform, + }) + if (resolved.status !== 'release') { + return feedErrorResponse(resolved, 'manifest', { [FEED_STATUS_HEADER]: 'no-release' }) + } + return new NextResponse(resolved.assets.manifest, { + status: 200, + headers: { + 'content-type': 'text/yaml; charset=utf-8', + 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}`, + [FEED_STATUS_HEADER]: 'release', + }, + }) +} + +/** + * The response to a feed with no release to offer: 502 when GitHub could not be read or no + * release carries a valid `artifact`, 404 (with `noReleaseHeaders`) when the channel has none. + */ +export function feedErrorResponse( + resolved: Exclude, + artifact: 'installer' | 'manifest', + noReleaseHeaders?: Record +): NextResponse { + switch (resolved.status) { + case 'unavailable': + return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) + case 'invalid': + return NextResponse.json({ error: `Release ${artifact} unavailable` }, { status: 502 }) + case 'no-release': + return NextResponse.json( + { error: `No desktop release for channel ${resolved.channel}` }, + { status: 404, headers: noReleaseHeaders } + ) + } +} diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 0a0269fe12e..bd36d899023 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -105,9 +105,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // The macOS desktop shell overlays native traffic lights on the // workspace. Mark it before first paint so the sidebar reserves // its inset title-bar lane without a post-hydration layout shift. + // Shells that predate the platform field are all macOS. var collapsedSidebarWidth = 48; try { - if (window.simDesktop && /Mac/i.test(navigator.userAgent)) { + if (window.simDesktop && (window.simDesktop.platform || 'darwin') === 'darwin') { document.documentElement.setAttribute('data-sim-desktop-title-bar', 'inset'); collapsedSidebarWidth = 0; } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index 4fae5497216..be854d2d9f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -60,6 +60,7 @@ import { showBrowserToolbarMenu, supportsAtomicBrowserPanelOcclusion, } from '@/lib/browser-agent/transport' +import { formatShortcut } from '@/lib/core/utils/platform' import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopBrowserAppearanceTheme, @@ -1312,7 +1313,7 @@ export function BrowserSession({ > Find in Page - ⌘F + {formatShortcut('F')} void @@ -18,15 +19,15 @@ export function ResourceZoomMenuItems({ <> Zoom In - ⌘+ + {formatShortcut('+')} Zoom Out - ⌘− + {formatShortcut('−')} Actual Size - ⌘0 + {formatShortcut('0')} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu.tsx index 4073f15c31f..796f4bfcaf2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu.tsx @@ -29,6 +29,7 @@ import { Trash, X, } from '@sim/emcn/icons' +import { formatShortcut } from '@/lib/core/utils/platform' import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -133,7 +134,7 @@ export function TerminalContextMenu({ Clear - ⌘R + {formatShortcut('R')} {onAppearanceThemeChange && ( @@ -181,12 +182,12 @@ export function TerminalContextMenu({ New Tab - ⌘T + {formatShortcut('T')} Close Terminal - ⌘W + {formatShortcut('W')} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 2d7e2ccae20..7912bf76ee9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -21,6 +21,7 @@ import { useTheme } from 'next-themes' import { useContextMenu } from '@/hooks/use-context-menu' import '@xterm/xterm/css/xterm.css' import type { TerminalTabsState } from '@sim/terminal-protocol' +import { formatShortcut } from '@/lib/core/utils/platform' import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopTerminalAppearance, @@ -579,7 +580,7 @@ const TerminalView = memo(function TerminalView({ }) return } - toast.error('Could not paste from the clipboard. Press ⌘V to paste.') + toast.error(`Could not paste from the clipboard. Press ${formatShortcut('V')} to paste.`) })() } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx index 8bcc7e97f5f..5d421e928bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -96,7 +96,7 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta const copy = useCallback(async () => { const bridge = getDesktopBridge()?.browserCredentials - if (!bridge) return + if (!bridge?.copy) return setBusy(true) try { await bridge.copy(credential.id) @@ -123,6 +123,8 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta }, [credential.id, hide, onBack, onForgotten]) const site = siteLabel(credential.origin) + // Absent where the OS clipboard keeps history Sim cannot clear (Windows). + const canCopy = typeof getDesktopBridge()?.browserCredentials?.copy === 'function' return ( <> - - - - - Copy password - + {canCopy && ( + + + + + Copy password + + )} } /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index b96eae87fb4..395f4210815 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import type { DesktopPreferenceKey, DesktopPreferences } from '@sim/desktop-bridge' import { Label, Switch, toast } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' -import { getDesktopBridge, getDesktopShellVersion } from '@/lib/desktop' +import { getDesktopBridge, getDesktopPlatform, getDesktopShellVersion } from '@/lib/desktop' import { ComputerUseSettings } from '@/app/workspace/[workspaceId]/settings/components/desktop/computer-use' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -126,7 +126,11 @@ export function Desktop() { )} void updatePreference('trayEnabled', checked)} diff --git a/apps/sim/lib/api/contracts/desktop-update.ts b/apps/sim/lib/api/contracts/desktop-update.ts new file mode 100644 index 00000000000..d5e818447b8 --- /dev/null +++ b/apps/sim/lib/api/contracts/desktop-update.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const desktopUpdatePlatformSchema = z.enum(['mac', 'windows']) + +/** The desktop platforms the update feed serves releases for. */ +export type DesktopUpdatePlatform = z.output + +/** + * Redirects to the newest installer of this deployment's channel for a + * platform. Public and session-less: it only points at public GitHub release + * assets. Shells that predate Windows support omit `platform`, so it defaults + * to `mac`. + */ +export const downloadDesktopUpdateContract = defineRouteContract({ + method: 'GET', + path: '/api/desktop/update/download', + query: z.object({ + platform: desktopUpdatePlatformSchema.default('mac'), + }), + response: { mode: 'redirect', status: 302 }, +}) diff --git a/apps/sim/lib/core/utils/platform.ts b/apps/sim/lib/core/utils/platform.ts index a2b56e8291b..7532e22904a 100644 --- a/apps/sim/lib/core/utils/platform.ts +++ b/apps/sim/lib/core/utils/platform.ts @@ -6,3 +6,11 @@ export function isMacPlatform(): boolean { if (typeof window === 'undefined') return false return /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent) } + +/** + * Labels a primary-modifier shortcut: `⌘F` on macOS, `Ctrl+F` elsewhere, where + * the desktop shell and browsers bind the same command to Control. + */ +export function formatShortcut(key: string): string { + return isMacPlatform() ? `⌘${key}` : `Ctrl+${key}` +} diff --git a/apps/sim/lib/desktop/index.test.ts b/apps/sim/lib/desktop/index.test.ts index d959b8b1c20..f21a6ed35f1 100644 --- a/apps/sim/lib/desktop/index.test.ts +++ b/apps/sim/lib/desktop/index.test.ts @@ -150,6 +150,7 @@ describe('desktop surface availability', () => { running: oversizedValue, interactive: false, active: true, + shell: oversizedValue, }, ], })), @@ -168,6 +169,7 @@ describe('desktop surface availability', () => { cwd: 'x'.repeat(1024), running: 'x'.repeat(1024), active: true, + shell: 'x'.repeat(32), }, ]) }) diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 307c72b9a75..e92a02d14b3 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -22,10 +22,11 @@ * capabilities consistently. */ import type { BrowserKnownSession } from '@sim/browser-protocol' -import type { DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' +import type { DesktopPlatform, DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' import { truncate } from '@sim/utils/string' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, + DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, } from '@/lib/mothership/chat/desktop-capabilities' @@ -134,6 +135,15 @@ export function getDesktopShellVersion(): string | undefined { return getDesktopBridge()?.version } +/** + * The shell's operating system, or undefined in a browser. Shells that predate + * the field are all macOS. + */ +export function getDesktopPlatform(): DesktopPlatform | undefined { + const bridge = getDesktopBridge() + return bridge ? (bridge.platform ?? 'darwin') : undefined +} + /** The shell updater surface, when the installed shell provides one. */ export function getDesktopUpdates(): SimDesktopApi['updates'] | undefined { return getDesktopBridge()?.updates @@ -150,6 +160,8 @@ export interface DesktopTerminalHint { running?: string interactive?: boolean active?: boolean + /** The shell's program name (`zsh`, `pwsh`, `powershell`), so commands are written for it. */ + shell?: string } export interface DesktopChatCapabilities { @@ -160,6 +172,7 @@ export interface DesktopChatCapabilities { browser?: true terminal?: true computerUse?: true + platform?: DesktopPlatform browserSessions?: BrowserKnownSession[] terminals?: DesktopTerminalHint[] /** The desktop's background executor, offered to run this turn's desktop tools. */ @@ -213,6 +226,9 @@ export async function getDesktopChatCapabilities( : {}), ...(tab.interactive ? { interactive: true as const } : {}), ...(tab.active ? { active: true as const } : {}), + ...(tab.shell + ? { shell: truncate(tab.shell, DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH, '') } + : {}), })) ) .catch(() => []) @@ -224,6 +240,7 @@ export async function getDesktopChatCapabilities( .then((state) => state.sessions) .catch(() => []) : [] + const platform = getDesktopPlatform() // Shells without a background executor (every build before it ships) leave the turn unbound. const executorDevice = bridge?.desktopExecutor ? await bridge.desktopExecutor.getDevice().catch(() => null) @@ -238,6 +255,7 @@ export async function getDesktopChatCapabilities( ...(browser ? { browser: true as const } : {}), ...(terminal ? { terminal: true as const } : {}), ...(computerUse ? { computerUse: true as const } : {}), + ...(platform ? { platform } : {}), ...(terminals.length > 0 ? { terminals } : {}), ...(browserSessions.length > 0 ? { browserSessions } : {}), ...(executorDevice diff --git a/apps/sim/lib/desktop/update-feed.test.ts b/apps/sim/lib/desktop/update-feed.test.ts index bd2f6817c73..888cddc0317 100644 --- a/apps/sim/lib/desktop/update-feed.test.ts +++ b/apps/sim/lib/desktop/update-feed.test.ts @@ -5,6 +5,7 @@ import { MANIFEST_ASSET_NAME, rewriteManifestUrls, selectReleaseForChannel, + WINDOWS_MANIFEST_ASSET_NAME, } from '@/lib/desktop/update-feed' function release( @@ -32,22 +33,22 @@ describe('selectReleaseForChannel', () => { ] it('offers stable-only to the latest channel', () => { - expect(selectReleaseForChannel(releases, 'latest')?.tag_name).toBe('v0.5.24') + expect(selectReleaseForChannel(releases, 'latest', 'mac')?.tag_name).toBe('v0.5.24') }) it('offers only staging builds to the staging stream', () => { - expect(selectReleaseForChannel(releases, 'staging')?.tag_name).toBe('v0.5.25-staging.2') + expect(selectReleaseForChannel(releases, 'staging', 'mac')?.tag_name).toBe('v0.5.25-staging.2') }) it('offers only dev builds to the dev stream, never staging builds', () => { - expect(selectReleaseForChannel(releases, 'dev')?.tag_name).toBe('v0.5.25-dev.412') + expect(selectReleaseForChannel(releases, 'dev', 'mac')?.tag_name).toBe('v0.5.25-dev.412') }) it('keeps already-published alpha and beta releases eligible during migration', () => { - expect(selectReleaseForChannel([release('v0.5.25-alpha.412')], 'dev')?.tag_name).toBe( + expect(selectReleaseForChannel([release('v0.5.25-alpha.412')], 'dev', 'mac')?.tag_name).toBe( 'v0.5.25-alpha.412' ) - expect(selectReleaseForChannel([release('v0.5.25-beta.2')], 'staging')?.tag_name).toBe( + expect(selectReleaseForChannel([release('v0.5.25-beta.2')], 'staging', 'mac')?.tag_name).toBe( 'v0.5.25-beta.2' ) }) @@ -57,14 +58,16 @@ describe('selectReleaseForChannel', () => { // Sim Staging); a stable Sim.app artifact can't be applied by those // shells, so a newer stable must not shadow the channel's own builds. const withNewStable = [...releases, release('v0.5.25')] - expect(selectReleaseForChannel(withNewStable, 'dev')?.tag_name).toBe('v0.5.25-dev.412') - expect(selectReleaseForChannel(withNewStable, 'staging')?.tag_name).toBe('v0.5.25-staging.2') - expect(selectReleaseForChannel(withNewStable, 'latest')?.tag_name).toBe('v0.5.25') + expect(selectReleaseForChannel(withNewStable, 'dev', 'mac')?.tag_name).toBe('v0.5.25-dev.412') + expect(selectReleaseForChannel(withNewStable, 'staging', 'mac')?.tag_name).toBe( + 'v0.5.25-staging.2' + ) + expect(selectReleaseForChannel(withNewStable, 'latest', 'mac')?.tag_name).toBe('v0.5.25') }) it('skips stable-tagged releases flagged prerelease on the latest channel', () => { const flagged = [release('v0.5.25', { prerelease: true }), release('v0.5.24')] - expect(selectReleaseForChannel(flagged, 'latest')?.tag_name).toBe('v0.5.24') + expect(selectReleaseForChannel(flagged, 'latest', 'mac')?.tag_name).toBe('v0.5.24') }) it('keeps releases missing the updater manifest eligible for candidate validation', () => { @@ -72,7 +75,9 @@ describe('selectReleaseForChannel', () => { release('v0.5.25-dev.413', { assets: [{ name: 'Sim-0.5.25-dev.413-universal.dmg' }] }), release('v0.5.25-dev.412'), ] - expect(selectReleaseForChannel(withBrokenNewest, 'dev')?.tag_name).toBe('v0.5.25-dev.413') + expect(selectReleaseForChannel(withBrokenNewest, 'dev', 'mac')?.tag_name).toBe( + 'v0.5.25-dev.413' + ) }) }) @@ -95,7 +100,8 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', repository, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' ) expect(rewritten).toContain( ` - url: https://github.com/${repository}/releases/download/v0.5.24/Sim-0.5.24-universal.zip` @@ -113,7 +119,8 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' ) ).toBe( ` - url: https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v0.5.24/Sim-0.5.24-universal.zip` @@ -127,7 +134,8 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' ) ).toBeNull() }) @@ -135,7 +143,7 @@ describe('rewriteManifestUrls', () => { it('rejects an expected artifact that is absent from the release', () => { const manifest = ' - url: Sim-0.5.24-universal.zip' expect( - rewriteManifestUrls(manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, new Set()) + rewriteManifestUrls(manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, new Set(), 'mac') ).toBeNull() }) @@ -146,7 +154,71 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' + ) + ).toBeNull() + }) +}) + +describe('Windows feed', () => { + const windowsRelease = (tag: string) => + release(tag, { + assets: [ + { name: MANIFEST_ASSET_NAME }, + { name: WINDOWS_MANIFEST_ASSET_NAME }, + { name: `Sim-${tag.replace(/^v/, '')}-x64.exe` }, + ], + }) + + it('skips a newer release that carries no Windows build instead of rejecting it', () => { + const releases = [release('v0.5.25'), windowsRelease('v0.5.24')] + expect(selectReleaseForChannel(releases, 'latest', 'windows')?.tag_name).toBe('v0.5.24') + expect(selectReleaseForChannel(releases, 'latest', 'mac')?.tag_name).toBe('v0.5.25') + }) + + it('rewrites the Windows installer entry to its absolute release asset', () => { + const manifest = [ + 'version: 0.5.24', + 'files:', + ' - url: Sim-0.5.24-x64.exe', + ' sha512: abc', + 'path: Sim-0.5.24-x64.exe', + ].join('\n') + expect( + rewriteManifestUrls( + manifest, + 'v0.5.24', + DESKTOP_STABLE_RELEASE_REPOSITORY, + new Set(['Sim-0.5.24-x64.exe']), + 'windows' + ) + ).toContain( + ` - url: https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v0.5.24/Sim-0.5.24-x64.exe` + ) + }) + + it('rejects a Windows manifest that points at macOS artifacts', () => { + expect( + rewriteManifestUrls( + ' - url: Sim-0.5.24-universal.zip', + 'v0.5.24', + DESKTOP_STABLE_RELEASE_REPOSITORY, + new Set(['Sim-0.5.24-universal.zip']), + 'windows' + ) + ).toBeNull() + }) + + it('rejects a Windows manifest naming an architecture the feed does not ship', () => { + const manifest = [' - url: Sim-0.5.24-x64.exe', ' - url: Sim-0.5.24-arm64.exe'].join('\n') + expect( + rewriteManifestUrls( + manifest, + 'v0.5.24', + DESKTOP_STABLE_RELEASE_REPOSITORY, + new Set(['Sim-0.5.24-x64.exe', 'Sim-0.5.24-arm64.exe']), + 'windows' ) ).toBeNull() }) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index f6ba20a3788..19fb42c65b3 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -2,8 +2,8 @@ * Per-environment desktop update feed resolution. * * Installed desktop shells ask the Sim deployment they are pointed at — - * `GET /api/desktop/update/latest-mac.yml` — instead of a global - * GitHub feed, so each environment independently controls which shell build + * `GET /api/desktop/update/latest-mac.yml` on macOS, `latest.yml` on + * Windows — instead of a global GitHub feed, so each environment independently controls which shell build * its clients are offered. The environment IS the channel: * * - hosted `dev` deployment → `dev` (per-push prerelease builds from `dev`) @@ -24,11 +24,22 @@ * stable prod-identity artifact to a dev shell would offer an update * Squirrel.Mac cannot apply (bundle-id mismatch) — each channel only ever * moves forward on its own artifacts. + * + * Platforms are published independently: every release carries the macOS + * build, while the Windows build is signed and uploaded by its own job and may + * be absent. A release without the Windows manifest is simply not a Windows + * release, so Windows shells keep the newest release that has one. */ +import { DESKTOP_UPDATE_ARTIFACTS, type DesktopUpdateArtifacts } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import { compareVersions } from '@/lib/desktop/min-version' +const logger = createLogger('DesktopUpdateFeed') + export const DESKTOP_STABLE_RELEASE_REPOSITORY = 'simstudioai/sim' export const DESKTOP_PRERELEASE_REPOSITORY = 'simstudioai/sim-desktop-releases' @@ -39,18 +50,14 @@ export type DesktopReleaseRepository = export type DesktopUpdateChannel = 'dev' | 'staging' | 'latest' /** Maps Sim's server-controlled deployment environment to its update channel. */ -export function channelForDeploymentEnvironment( - environment: string | undefined -): DesktopUpdateChannel { +function channelForDeploymentEnvironment(environment: string | undefined): DesktopUpdateChannel { if (environment === 'dev') return 'dev' if (environment === 'staging') return 'staging' return 'latest' } /** Keeps stable and prerelease release storage isolated by channel. */ -export function releaseRepositoryForChannel( - channel: DesktopUpdateChannel -): DesktopReleaseRepository { +function releaseRepositoryForChannel(channel: DesktopUpdateChannel): DesktopReleaseRepository { return channel === 'latest' ? DESKTOP_STABLE_RELEASE_REPOSITORY : DESKTOP_PRERELEASE_REPOSITORY } @@ -61,13 +68,21 @@ export function channelOfVersion(version: string): DesktopUpdateChannel { return 'latest' } -/** - * The manifest asset every desktop build uploads. electron-builder's GitHub - * provider always names it `latest-mac.yml` regardless of the version's - * prerelease tag (channels are a generic-provider concept); which channel a - * release belongs to is carried entirely by its tag. - */ -export const MANIFEST_ASSET_NAME = 'latest-mac.yml' +interface DesktopPlatformFeed extends DesktopUpdateArtifacts { + /** + * Whether every desktop release must carry this platform. A missing manifest + * then marks a broken release; otherwise the release just lacks the platform. + */ + inEveryRelease: boolean +} + +const PLATFORM_FEEDS: Record = { + mac: { ...DESKTOP_UPDATE_ARTIFACTS.mac, inEveryRelease: true }, + windows: { ...DESKTOP_UPDATE_ARTIFACTS.windows, inEveryRelease: false }, +} + +export const MANIFEST_ASSET_NAME = PLATFORM_FEEDS.mac.manifest +export const WINDOWS_MANIFEST_ASSET_NAME = PLATFORM_FEEDS.windows.manifest export const MAX_DESKTOP_UPDATE_MANIFEST_BYTES = 256 * 1024 /** The subset of the GitHub releases API the feed needs. */ @@ -86,11 +101,16 @@ export interface DesktopReleaseCandidate { */ function releasesForChannel( releases: DesktopReleaseCandidate[], - channel: DesktopUpdateChannel + channel: DesktopUpdateChannel, + platform: DesktopUpdatePlatform ): DesktopReleaseCandidate[] { + const feed = PLATFORM_FEEDS[platform] const candidates: Array<{ release: DesktopReleaseCandidate; version: string }> = [] for (const release of releases) { if (release.draft) continue + if (!feed.inEveryRelease && !release.assets?.some((asset) => asset.name === feed.manifest)) { + continue + } const version = release.tag_name.replace(/^v/, '') if (channelOfVersion(version) !== channel) continue // Defense in depth: a bare vX.Y.Z tag manually marked "pre-release" on @@ -106,9 +126,10 @@ function releasesForChannel( /** Picks the newest release that passes the channel and version checks. */ export function selectReleaseForChannel( releases: DesktopReleaseCandidate[], - channel: DesktopUpdateChannel + channel: DesktopUpdateChannel, + platform: DesktopUpdatePlatform ): DesktopReleaseCandidate | null { - return releasesForChannel(releases, channel)[0] ?? null + return releasesForChannel(releases, channel, platform)[0] ?? null } /** @@ -122,11 +143,12 @@ export function rewriteManifestUrls( manifest: string, tag: string, repository: DesktopReleaseRepository, - availableAssetNames: ReadonlySet + availableAssetNames: ReadonlySet, + platform: DesktopUpdatePlatform ): string | null { const base = `https://github.com/${repository}/releases/download/${tag}/` const version = tag.replace(/^v/, '') - const expectedNames = new Set([`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`]) + const expectedNames = new Set(PLATFORM_FEEDS[platform].artifactNames(version)) let valid = true let hasUpdaterFile = false const rewritten = manifest.replace( @@ -168,19 +190,21 @@ export const DESKTOP_RELEASES_PAGE_SIZE = 100 */ export const MAX_DESKTOP_RELEASE_PAGES = 5 -export interface DesktopReleaseAssets { +interface DesktopReleaseAssets { manifest: string installer: { name: string; browser_download_url: string } } /** Reads and validates the complete artifact set required to offer a release. */ -export async function resolveReleaseAssets( +async function resolveReleaseAssets( release: DesktopReleaseCandidate, repository: DesktopReleaseRepository, + platform: DesktopUpdatePlatform, fetchManifest: (url: string) => Promise ): Promise { - const manifestAsset = release.assets?.find((asset) => asset.name === MANIFEST_ASSET_NAME) - const installer = selectInstallerAsset(release, repository) + const manifestAssetName = PLATFORM_FEEDS[platform].manifest + const manifestAsset = release.assets?.find((asset) => asset.name === manifestAssetName) + const installer = selectInstallerAsset(release, repository, platform) if (!manifestAsset || !installer) return null try { @@ -194,7 +218,13 @@ export async function resolveReleaseAssets( if (/^version:\s*(\S+)\s*$/m.exec(source)?.[1] !== version) return null const availableAssetNames = new Set(release.assets?.map((asset) => asset.name)) - const manifest = rewriteManifestUrls(source, release.tag_name, repository, availableAssetNames) + const manifest = rewriteManifestUrls( + source, + release.tag_name, + repository, + availableAssetNames, + platform + ) return manifest ? { manifest, installer } : null } catch { return null @@ -219,12 +249,13 @@ export function releasesApiUrl(repository: DesktopReleaseRepository, page: numbe * through to the next version. `fetchPage` returning null remains fatal because * an unreadable page could hide a newer valid release. */ -export async function resolveLatestRelease( +async function resolveLatestRelease( channel: DesktopUpdateChannel, + platform: DesktopUpdatePlatform, fetchPage: (page: number) => Promise, - resolveCandidate: (release: DesktopReleaseCandidate) => T | null | Promise + resolveCandidate: (release: DesktopReleaseCandidate) => Promise ): Promise< - | { release: DesktopReleaseCandidate; value: T } + | { release: DesktopReleaseCandidate; assets: DesktopReleaseAssets } | { release: null; rejectedCandidates: boolean } | { error: 'fetch-failed' } > { @@ -232,9 +263,9 @@ export async function resolveLatestRelease( for (let page = 1; page <= MAX_DESKTOP_RELEASE_PAGES; page++) { const releases = await fetchPage(page) if (releases === null) return { error: 'fetch-failed' } - for (const release of releasesForChannel(releases, channel)) { - const value = await resolveCandidate(release) - if (value !== null) return { release, value } + for (const release of releasesForChannel(releases, channel, platform)) { + const assets = await resolveCandidate(release) + if (assets !== null) return { release, assets } rejectedCandidates = true } // A short page is the end of the list; nothing older remains to walk. @@ -244,25 +275,108 @@ export async function resolveLatestRelease( } /** - * The human-installable artifact of a release, preferred over the zip the - * updater consumes. Selected per-release rather than through GitHub's - * repository-wide "latest release", which the stable repository shares with - * web-app and SDK tags that carry no desktop artifact at all. + * The human-installable artifact of a release for a platform (on macOS, the + * dmg over the zip the updater consumes). Selected per-release rather than + * through GitHub's repository-wide "latest release", which the stable + * repository shares with web-app and SDK tags that carry no desktop artifact + * at all. */ export function selectInstallerAsset( release: DesktopReleaseCandidate, - repository: DesktopReleaseRepository + repository: DesktopReleaseRepository, + platform: DesktopUpdatePlatform ): { name: string; browser_download_url: string } | null { const assets = release.assets ?? [] const version = release.tag_name.replace(/^v/, '') - const dmgName = `Sim-${version}-universal.dmg` - const zipName = `Sim-${version}-universal.zip` - const asset = - assets.find((candidate) => candidate.name === dmgName) ?? - assets.find((candidate) => candidate.name === zipName) + const asset = PLATFORM_FEEDS[platform] + .artifactNames(version) + .map((name) => assets.find((candidate) => candidate.name === name)) + .find((candidate) => candidate !== undefined) if (!asset) return null return { name: asset.name, browser_download_url: `https://github.com/${repository}/releases/download/${release.tag_name}/${asset.name}`, } } + +/** How a deployment's feed resolved for one platform. */ +export type DesktopFeedResolution = + | { status: 'release'; channel: DesktopUpdateChannel; assets: DesktopReleaseAssets } + /** The channel has no release for the platform yet: an authoritative answer, not an outage. */ + | { status: 'no-release'; channel: DesktopUpdateChannel } + /** Releases exist, but none carries a complete, valid artifact set. */ + | { status: 'invalid'; channel: DesktopUpdateChannel } + /** GitHub could not be read, so a newer valid release may be hidden. */ + | { status: 'unavailable'; channel: DesktopUpdateChannel } + +/** Seconds a resolved release (and the GitHub reads behind it) may be stale. */ +export const DESKTOP_FEED_REVALIDATE_SECONDS = 300 + +/** + * Resolves the newest complete release a deployment offers to one platform, + * reading GitHub through Next's fetch cache. The feed serves its rewritten + * manifest and the download redirect its installer, so both routes always + * agree on the release. + * + * `environment` is the server-controlled deployment environment, never a + * request header: this public lookup must not let a caller pick which + * app-identity release it serves. `githubToken` only raises the API quota. + */ +export async function resolveDeploymentRelease(options: { + environment: string | undefined + githubToken: string | undefined + platform: DesktopUpdatePlatform +}): Promise { + const { githubToken, platform } = options + const channel = channelForDeploymentEnvironment(options.environment) + const releaseRepository = releaseRepositoryForChannel(channel) + const resolved = await resolveLatestRelease( + channel, + platform, + async (page) => { + try { + const response = await fetch(releasesApiUrl(releaseRepository, page), { + headers: { + accept: 'application/vnd.github+json', + ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), + }, + next: { revalidate: DESKTOP_FEED_REVALIDATE_SECONDS }, + }) + if (!response.ok) { + logger.error('GitHub releases lookup failed', { + status: response.status, + page, + channel, + releaseRepository, + }) + return null + } + return (await response.json()) as DesktopReleaseCandidate[] + } catch (error) { + logger.error('GitHub releases response could not be read', { + message: getErrorMessage(error), + page, + channel, + releaseRepository, + }) + return null + } + }, + async (release) => { + const assets = await resolveReleaseAssets(release, releaseRepository, platform, (url) => + fetch(url, { next: { revalidate: DESKTOP_FEED_REVALIDATE_SECONDS } }) + ) + if (!assets) { + logger.warn('Skipping incomplete or invalid desktop release', { + tag: release.tag_name, + channel, + platform, + }) + } + return assets + } + ) + if ('error' in resolved) return { status: 'unavailable', channel } + if (resolved.release) return { status: 'release', channel, assets: resolved.assets } + return { status: resolved.rejectedCandidates ? 'invalid' : 'no-release', channel } +} diff --git a/apps/sim/lib/mothership/chat/desktop-capabilities.ts b/apps/sim/lib/mothership/chat/desktop-capabilities.ts index b8fb040f927..e619a9fb4e6 100644 --- a/apps/sim/lib/mothership/chat/desktop-capabilities.ts +++ b/apps/sim/lib/mothership/chat/desktop-capabilities.ts @@ -1,3 +1,5 @@ /** Wire limits shared by the desktop capability snapshot and its request schema. */ export const DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH = 64 export const DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH = 1024 +/** A shell's program name (`zsh`, `pwsh`), never a path. */ +export const DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH = 32 diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index b300f46a5d3..4659bdbb8b9 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -898,7 +898,8 @@ describe('desktop request capabilities', () => { model: 'gpt-6-astra', browser: true, terminalCapable: true, - terminals: [{ id: 'terminal-1', cwd: '/work/app', active: true }], + desktopPlatform: 'win32', + terminals: [{ id: 'terminal-1', cwd: 'C:\\work\\app', active: true, shell: 'pwsh' }], browserSessions: [ { hostname: 'example.com', evidence: 'cookies', lastObservedAt: '2026-09-08' }, ], @@ -907,7 +908,8 @@ describe('desktop request capabilities', () => { computerUse: false, browser: true, terminal: true, - terminals: [{ id: 'terminal-1', cwd: '/work/app', active: true }], + platform: 'win32', + terminals: [{ id: 'terminal-1', cwd: 'C:\\work\\app', active: true, shell: 'pwsh' }], browserSessions: [ { hostname: 'example.com', evidence: 'cookies', lastObservedAt: '2026-09-08' }, ], diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index a6cace00771..031e9e93325 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { BrowserKnownSession } from '@sim/browser-protocol' +import type { DesktopPlatform } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { isPermissionType, permissionSatisfies } from '@sim/platform-authz/predicates' import { toError } from '@sim/utils/errors' @@ -99,12 +100,15 @@ interface BuildPayloadParams { browser?: boolean terminalCapable?: boolean computerUse?: boolean + /** The desktop shell's operating system, so commands and paths are written for it. */ + desktopPlatform?: DesktopPlatform terminals?: Array<{ id: string cwd?: string running?: string interactive?: boolean active?: boolean + shell?: string }> browserSessions?: BrowserKnownSession[] } @@ -556,6 +560,7 @@ export async function buildCopilotRequestPayload(params: BuildPayloadParams): Pr browser: params.browser === true, terminal: params.terminalCapable === true, computerUse, + ...(params.desktopPlatform ? { platform: params.desktopPlatform } : {}), terminals: params.terminalCapable ? (params.terminals ?? []).slice(0, 20) : [], browserSessions: params.browser ? (params.browserSessions ?? []).slice(0, 20) : [], }, diff --git a/apps/sim/lib/mothership/chat/post.test.ts b/apps/sim/lib/mothership/chat/post.test.ts index 2b32e7fed05..17b005b3257 100644 --- a/apps/sim/lib/mothership/chat/post.test.ts +++ b/apps/sim/lib/mothership/chat/post.test.ts @@ -1052,6 +1052,26 @@ describe('handleUnifiedChatPost', () => { expect(persistChatResources).not.toHaveBeenCalled() }) + it("forwards the desktop's platform and each terminal's shell", async () => { + const terminals = [{ id: '1', cwd: 'C:\\work', active: true, shell: 'powershell' }] + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Build it', + workspaceId: 'ws-1', + createNewChat: true, + desktopCapabilities: { terminal: true, platform: 'win32', terminals }, + }), + }) + ) + + expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ desktopPlatform: 'win32', terminals }) + ) + }) + it('accepts and forwards more than eight open terminal hints', async () => { const terminals = Array.from({ length: 12 }, (_, index) => ({ id: String(index + 1), diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index dc5fbb0e553..97a22307e25 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -1,5 +1,6 @@ import { context as otelContextApi } from '@opentelemetry/api' import type { SessionPrincipal } from '@sim/auth/principal' +import { DESKTOP_PLATFORMS, type DesktopPlatform } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -40,6 +41,7 @@ import { buildOnComplete, buildOnError } from '@/lib/mothership/chat/completion' import { MAX_CHAT_MESSAGE_LENGTH } from '@/lib/mothership/chat/context-limits' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, + DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, } from '@/lib/mothership/chat/desktop-capabilities' import { @@ -163,6 +165,8 @@ const ChatMessageSchema = z browser: z.boolean().optional(), terminal: z.boolean().optional(), computerUse: z.boolean().optional(), + /** Optional: shells that predate it are all macOS. */ + platform: z.enum(DESKTOP_PLATFORMS).optional(), terminals: z .array( z.object({ @@ -171,6 +175,7 @@ const ChatMessageSchema = z running: z.string().max(DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH).optional(), interactive: z.boolean().optional(), active: z.boolean().optional(), + shell: z.string().max(DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH).optional(), }) ) .optional(), @@ -287,6 +292,7 @@ type UnifiedChatBranch = browser?: boolean terminalCapable?: boolean computerUse?: boolean + desktopPlatform?: DesktopPlatform terminals?: Terminals browserSessions?: BrowserSessions }) => Promise @@ -329,6 +335,7 @@ type UnifiedChatBranch = browser?: boolean terminalCapable?: boolean computerUse?: boolean + desktopPlatform?: DesktopPlatform terminals?: Terminals browserSessions?: BrowserSessions }) => Promise @@ -627,6 +634,7 @@ async function resolveBranch(params: { browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, computerUse: payloadParams.computerUse, + desktopPlatform: payloadParams.desktopPlatform, terminals: payloadParams.terminals, browserSessions: payloadParams.browserSessions, }), @@ -691,6 +699,7 @@ async function resolveBranch(params: { browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, computerUse: payloadParams.computerUse, + desktopPlatform: payloadParams.desktopPlatform, terminals: payloadParams.terminals, browserSessions: payloadParams.browserSessions, }), @@ -1206,6 +1215,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { browser: body.desktopCapabilities?.browser === true, terminalCapable: body.desktopCapabilities?.terminal === true, computerUse: body.desktopCapabilities?.computerUse === true, + desktopPlatform: body.desktopCapabilities?.platform, terminals: body.desktopCapabilities?.terminals, browserSessions: body.desktopCapabilities?.browserSessions, }) @@ -1234,6 +1244,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { browser: body.desktopCapabilities?.browser === true, terminalCapable: body.desktopCapabilities?.terminal === true, computerUse: body.desktopCapabilities?.computerUse === true, + desktopPlatform: body.desktopCapabilities?.platform, terminals: body.desktopCapabilities?.terminals, browserSessions: body.desktopCapabilities?.browserSessions, }) diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index f42d7216caa..f1816b888fa 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -106,6 +106,8 @@ export type ModelSelection = z.infer; /** Desktop capabilities and bounded session hints, supplied by Sim for this turn. */ export const DesktopContextSchema = z.object({ + /** The desktop host OS; absent from builds that predate Windows support, which means macOS. */ + platform: z.enum(["darwin", "win32", "linux"]).optional(), localFiles: z.boolean().optional(), computerUse: z.boolean().default(false), browser: z.boolean().default(false), @@ -118,6 +120,8 @@ export const DesktopContextSchema = z.object({ running: z.string().max(1024).optional(), interactive: z.boolean().optional(), active: z.boolean().optional(), + /** The short shell kind, e.g. "zsh", "bash", "pwsh", "powershell" or "cmd". */ + shell: z.string().max(32).optional(), }), ) .max(20) diff --git a/apps/sim/lib/terminal/tab-label.ts b/apps/sim/lib/terminal/tab-label.ts index 08f783888f7..fdc41157cfa 100644 --- a/apps/sim/lib/terminal/tab-label.ts +++ b/apps/sim/lib/terminal/tab-label.ts @@ -3,7 +3,7 @@ import { describeRunningCommand, type TerminalTabState } from '@sim/terminal-pro /** Full working directory, plus a concise name for whatever the shell is running. */ export function terminalTooltip(tab: TerminalTabState): string { const where = tab.cwd ?? 'Terminal' - return tab.running ? `${where} — ${describeRunningCommand(tab.running)}` : where + return tab.running ? `${where} — ${describeRunningCommand(tab.running, tab.shell)}` : where } /** diff --git a/bun.lock b/bun.lock index 01773fa57c9..2a09c6f9458 100644 --- a/bun.lock +++ b/bun.lock @@ -48,8 +48,6 @@ "version": "0.0.0", "dependencies": { "@lydell/node-pty": "1.2.0-beta.12", - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@sim/auth-ui": "workspace:*", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", @@ -86,6 +84,12 @@ "typescript": "^7.0.2", "vitest": "^5.1.0", }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", + }, }, "apps/docs": { "name": "docs", diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 526273b05b3..fab7444da68 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -633,7 +633,7 @@ interface SimDesktopBrowserCredentialsApi { * This is the only method on the entire bridge that can produce password * plaintext, and it is heavily conditioned: it requires an active user * gesture, the shell prompts for Touch ID (or a native confirmation where - * Touch ID is unavailable) on every call, and it returns exactly one + * Touch ID is unavailable, including every Windows machine) on every call, and it returns exactly one * password. Resolves null when the user declines or the credential is gone. */ reveal(id: string): Promise @@ -641,10 +641,16 @@ interface SimDesktopBrowserCredentialsApi { * Copy one saved password to the clipboard. Same authorization as * {@link reveal}, but the password never enters the renderer: the shell * writes the clipboard itself and clears it again shortly after. + * + * Absent on Windows, where Clipboard History and the cloud clipboard keep a + * copy that clearing the clipboard does not remove. */ - copy(id: string): Promise - /** Copy saved passwords out of a Chrome profile into the vault. */ - importFromChrome( + copy?(id: string): Promise + /** + * Copy saved passwords out of a Chrome profile into the vault. Absent where + * browser import is unavailable (everything but macOS). + */ + importFromChrome?( profileId?: string, policy?: BrowserCredentialConflictPolicy ): Promise @@ -986,7 +992,7 @@ export interface DesktopPreferences { notificationsOnlyWhenUnfocused: boolean launchAtLogin: boolean autoDownloadUpdates: boolean - /** Show the Sim status item (recent chats menu) in the macOS menu bar. */ + /** Show the Sim status item (recent chats menu) in the macOS menu bar or the Windows system tray. */ trayEnabled: boolean /** Let Chat drive the built-in agent browser on this device. */ browserEnabled: boolean @@ -1223,10 +1229,18 @@ interface SimDesktopExecutorApi { getDevice(): Promise } +/** The operating systems a desktop shell runs on. */ +export const DESKTOP_PLATFORMS = ['darwin', 'win32', 'linux'] as const + +/** The operating system a desktop shell runs on. */ +export type DesktopPlatform = (typeof DESKTOP_PLATFORMS)[number] + export interface SimDesktopApi { computerUse?: SimDesktopComputerUseApi /** Installed shell version (plain semver, e.g. `0.3.1`). */ version: string + /** The shell's operating system. Absent on shells that predate it, all of which are macOS. */ + platform?: DesktopPlatform openExternal(url: string): Promise /** Opens the operating system's microphone privacy settings when supported. */ openMicrophoneSettings?(): Promise @@ -1269,7 +1283,7 @@ export interface SimDesktopApi { browserAgent: SimDesktopBrowserAgentApi /** * Local Chrome import for the built-in browser. Absent on platforms without - * a supported importer. + * a supported importer (everything but macOS today). */ browserImport?: SimDesktopBrowserImportApi /** Saved passwords and user-driven fill for the built-in browser. */ @@ -1280,7 +1294,7 @@ export interface SimDesktopApi { * desktop tools to the chat view. */ desktopExecutor?: SimDesktopExecutorApi - /** Reads and selects Terminal.app or iTerm2 color profiles on macOS. */ + /** Reads and selects Terminal.app or iTerm2 color profiles. Present only on macOS. */ terminalThemes?: SimDesktopTerminalThemesApi } export { @@ -1294,3 +1308,4 @@ export { type DesktopTitleBarMode, observeDesktopTitleBar, } from './title-bar' +export { DESKTOP_UPDATE_ARTIFACTS, type DesktopUpdateArtifacts } from './update-artifacts' diff --git a/packages/desktop-bridge/src/title-bar.ts b/packages/desktop-bridge/src/title-bar.ts index 36ff3f0dd5c..b3cbf0cb3e3 100644 --- a/packages/desktop-bridge/src/title-bar.ts +++ b/packages/desktop-bridge/src/title-bar.ts @@ -1,10 +1,16 @@ -import type { SimDesktopWindowStateApi } from '@sim/desktop-bridge' +import type { DesktopPlatform, SimDesktopWindowStateApi } from '@sim/desktop-bridge' export type DesktopTitleBarMode = 'fullscreen' | 'inset' | null export const DESKTOP_TITLE_BAR_ATTRIBUTE = 'data-sim-desktop-title-bar' -function supportsDesktopTitleBar(userAgent: string, hasDesktopBridge: boolean): boolean { - return hasDesktopBridge && /Mac/i.test(userAgent) +interface TitleBarBridge { + platform?: DesktopPlatform + windowState?: SimDesktopWindowStateApi +} + +/** Only macOS draws traffic lights over the page; Windows keeps its native frame. */ +function supportsDesktopTitleBar(bridge: TitleBarBridge | undefined): boolean { + return bridge !== undefined && (bridge.platform ?? 'darwin') === 'darwin' } export function applyDesktopTitleBarMode( @@ -18,10 +24,9 @@ export function applyDesktopTitleBarMode( /** Shares native title-bar geometry and fullscreen transitions across hosted and offline pages. */ export function observeDesktopTitleBar( root: HTMLElement, - userAgent: string, - bridge?: { windowState?: SimDesktopWindowStateApi } + bridge?: TitleBarBridge ): (() => void) | undefined { - if (!supportsDesktopTitleBar(userAgent, Boolean(bridge))) { + if (!supportsDesktopTitleBar(bridge)) { applyDesktopTitleBarMode(root, null) return } diff --git a/packages/desktop-bridge/src/update-artifacts.ts b/packages/desktop-bridge/src/update-artifacts.ts new file mode 100644 index 00000000000..5d3f3898609 --- /dev/null +++ b/packages/desktop-bridge/src/update-artifacts.ts @@ -0,0 +1,26 @@ +/** + * The release artifacts each self-updating desktop platform publishes, shared by the shell's + * updater, which follows a manifest only to these names, and the deployment's update feed, which + * serves them. Both must agree, so they are named once here. + */ +export interface DesktopUpdateArtifacts { + /** + * The electron-updater manifest the build uploads and the shell requests. electron-builder + * names it the same for every channel; a release's channel is carried by its tag. + */ + manifest: 'latest-mac.yml' | 'latest.yml' + /** Every artifact a manifest for `version` may reference, most human-installable first. */ + artifactNames(version: string): readonly string[] +} + +export const DESKTOP_UPDATE_ARTIFACTS: Record<'mac' | 'windows', DesktopUpdateArtifacts> = { + mac: { + manifest: 'latest-mac.yml', + artifactNames: (version) => [`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`], + }, + windows: { + manifest: 'latest.yml', + // x64 only: a manifest naming any other architecture is not one Sim ships. + artifactNames: (version) => [`Sim-${version}-x64.exe`], + }, +} diff --git a/packages/sim-setup/src/desktop.test.ts b/packages/sim-setup/src/desktop.test.ts index 9dc1cea254b..0174cd2da16 100644 --- a/packages/sim-setup/src/desktop.test.ts +++ b/packages/sim-setup/src/desktop.test.ts @@ -11,6 +11,9 @@ function source(appUrl?: string, label = 'configuration') { } } +const MAC = { label: 'macOS', installerExtensions: ['.dmg', '.zip'] } +const WINDOWS = { label: 'Windows', installerExtensions: ['.exe'] } + function respond(status: number, headers: Record = {}): typeof fetch { return vi.fn(async () => new Response(null, { status, headers })) as unknown as typeof fetch } @@ -35,6 +38,7 @@ describe('probeDownload', () => { const result = await probeDownload( 'https://sim.example.com/x', + MAC, respond(302, { location: hostile }) ) @@ -45,9 +49,25 @@ describe('probeDownload', () => { }) }) + // A deployment older than Windows support ignores ?platform=windows and + // redirects to the macOS DMG. + it('refuses a macOS installer resolved for Windows', async () => { + const result = await probeDownload( + 'https://sim.example.com/api/desktop/update/download?platform=windows', + WINDOWS, + respond(302, { location: ASSET }) + ) + + expect(result).toEqual({ + status: 'wrong-platform', + platform: 'Windows', + installerName: 'Sim-1.2.3-universal.dmg', + }) + }) + it('does not follow the redirect', async () => { const impl = respond(302, { location: ASSET }) - await probeDownload('https://sim.example.com/x', impl) + await probeDownload('https://sim.example.com/x', MAC, impl) expect(impl).toHaveBeenCalledWith( 'https://sim.example.com/x', diff --git a/packages/sim-setup/src/desktop.ts b/packages/sim-setup/src/desktop.ts index 60132f633ad..a6a19ce692c 100644 --- a/packages/sim-setup/src/desktop.ts +++ b/packages/sim-setup/src/desktop.ts @@ -10,12 +10,35 @@ import { APP_URL } from './urls' /** * Where a deployment redirects to the newest installer for its own channel, - * and the manifest installed shells poll. Both ship in every Sim deployment - * (`app/api/desktop/update/*`), so a self-hosted install already serves them — - * there is nothing to build or host. + * and the manifest installed shells poll, per desktop platform. Both ship in + * every Sim deployment (`app/api/desktop/update/*`), so a self-hosted install + * already serves them — there is nothing to build or host. */ -const DOWNLOAD_PATH = '/api/desktop/update/download' -const FEED_PATH = '/api/desktop/update/latest-mac.yml' +const DESKTOP_PLATFORMS = { + mac: { + label: 'macOS', + installerExtensions: ['.dmg', '.zip'], + downloadPath: '/api/desktop/update/download', + feedPath: '/api/desktop/update/latest-mac.yml', + serverCommand: 'Sim → Server…', + serverCommandWhere: 'in the menu bar', + }, + windows: { + label: 'Windows', + installerExtensions: ['.exe'], + downloadPath: '/api/desktop/update/download?platform=windows', + feedPath: '/api/desktop/update/latest.yml', + serverCommand: 'File → Server…', + serverCommandWhere: 'in the menu bar (press Alt to show it)', + }, +} as const + +type DesktopPlatform = (typeof DESKTOP_PLATFORMS)[keyof typeof DESKTOP_PLATFORMS] + +/** The desktop build for the machine running the CLI; macOS unless it is Windows. */ +function desktopPlatformForHost(): DesktopPlatform { + return process.platform === 'win32' ? DESKTOP_PLATFORMS.windows : DESKTOP_PLATFORMS.mac +} const PROBE_TIMEOUT_MS = 15_000 @@ -134,6 +157,7 @@ export function resolveDeploymentUrl( export type DesktopProbe = | { status: 'ok'; installerUrl: string; installerName: string } + | { status: 'wrong-platform'; platform: string; installerName: string } | { status: 'no-release' } | { status: 'feed-unavailable' } | { status: 'unreachable'; error: string } @@ -143,9 +167,15 @@ export type DesktopProbe = * Asks the deployment to resolve its own installer, following no redirects: * the redirect's Location IS the answer, and downloading the artifact here * would pull hundreds of megabytes to check a link. + * + * The resolved name must carry one of the platform's installer extensions. A + * deployment older than Windows support ignores `?platform=windows` and + * redirects to the macOS DMG, which must not be reported as a Windows + * installer. */ export async function probeDownload( downloadUrl: string, + platform: { label: string; installerExtensions: readonly string[] }, fetchImpl: typeof fetch = fetch ): Promise { let response: Response @@ -166,7 +196,11 @@ export async function probeDownload( } catch { // Keep the raw Location; it is still the most useful thing to print. } - return { status: 'ok', installerUrl: location, installerName: sanitizeForTerminal(name) } + const installerName = sanitizeForTerminal(name) + const lowerName = name.toLowerCase() + if (!platform.installerExtensions.some((extension) => lowerName.endsWith(extension))) + return { status: 'wrong-platform', platform: platform.label, installerName } + return { status: 'ok', installerUrl: location, installerName } } if (response.status === 404) return { status: 'no-release' } if (response.status === 502) return { status: 'feed-unavailable' } @@ -185,6 +219,13 @@ export function describeProbe( switch (probe.status) { case 'ok': return { headline: `${glyph.pass} Installer resolved: ${probe.installerName}`, hints: [] } + case 'wrong-platform': + return { + headline: `${glyph.fail} This deployment does not offer a ${probe.platform} desktop build (it resolved ${probe.installerName}).`, + hints: [ + `Upgrade the Sim deployment to a release that publishes ${probe.platform} builds, or use the web app at ${appUrl}.`, + ], + } case 'no-release': return { headline: `${glyph.fail} This deployment reports no desktop release for its channel.`, @@ -219,15 +260,16 @@ export async function runDesktop(flags: DesktopFlags): Promise { // values`, so it is seconds of blocking work — and every bit of it is // discarded when --url already names the deployment. const appUrl = resolveDeploymentUrl(flags.url ? [] : discoverConfigurationSources(), flags.url) - const downloadUrl = `${appUrl}${DOWNLOAD_PATH}` + const platform = desktopPlatformForHost() + const downloadUrl = `${appUrl}${platform.downloadPath}` p.log.step(`Deployment: ${theme.accent(appUrl)}`) const spin = p.spinner() spin.start('Resolving the desktop installer…') const [probe, feedOk] = await Promise.all([ - probeDownload(downloadUrl), - httpHealth(`${appUrl}${FEED_PATH}`, PROBE_TIMEOUT_MS), + probeDownload(downloadUrl, platform), + httpHealth(`${appUrl}${platform.feedPath}`, PROBE_TIMEOUT_MS), ]) const { headline, hints } = describeProbe(probe, appUrl) spin.stop(headline) @@ -242,7 +284,7 @@ export async function runDesktop(flags: DesktopFlags): Promise { if (!feedOk) { p.log.warn( - `${FEED_PATH} did not resolve — the app will install but will not auto-update from this deployment.` + `${platform.feedPath} did not resolve — the app will install but will not auto-update from this deployment.` ) } @@ -251,13 +293,15 @@ export async function runDesktop(flags: DesktopFlags): Promise { `1. Download and install Sim:`, ` ${theme.accent(downloadUrl)}`, '', - `2. Open Sim, then choose ${theme.command('Sim → Server…')} in the menu bar.`, + `2. Open Sim, then choose ${theme.command(platform.serverCommand)} ${platform.serverCommandWhere}.`, '', `3. Enter your server URL and press Connect:`, ` ${theme.accent(appUrl)}`, '', theme.muted('Sim relaunches against your deployment and updates from it from then on.'), - theme.muted('The desktop app is macOS-only today; the web app works everywhere.'), + theme.muted( + 'The desktop app runs on macOS and Windows (x64, 10 1809 or later); the web app works everywhere.' + ), ].join('\n'), 'Connect the desktop app' ) diff --git a/packages/terminal-protocol/src/index.test.ts b/packages/terminal-protocol/src/index.test.ts index 541d7e2d8ed..4b7f125ea3a 100644 --- a/packages/terminal-protocol/src/index.test.ts +++ b/packages/terminal-protocol/src/index.test.ts @@ -26,4 +26,35 @@ describe('describeRunningCommand', () => { expect(describeRunningCommand('sudo /usr/bin/docker compose up')).toBe('docker') expect(describeRunningCommand('env FOO=1 nohup python train.py')).toBe('python') }) + + it('names a Windows program by its file name without the directory or .exe', () => { + expect(describeRunningCommand('C:\\tools\\node.exe server.js', 'pwsh')).toBe('node') + expect(describeRunningCommand('& "C:\\Git\\bin\\git.EXE" status', 'pwsh')).toBe('git') + }) + + it('keeps a quoted program path with spaces whole', () => { + expect(describeRunningCommand('& "C:\\Program Files\\Git\\bin\\git.exe" status', 'pwsh')).toBe( + 'git' + ) + expect(describeRunningCommand("& 'C:\\Program Files\\nodejs\\node.exe' x", 'powershell')).toBe( + 'node' + ) + expect(describeRunningCommand('"C:\\Program Files\\Git\\bin\\git.exe" status', 'cmd')).toBe( + 'git' + ) + expect(describeRunningCommand("'/opt/my tools/bin/deploy' --prod")).toBe('deploy') + }) + + it('reads a backslash as a path separator in Windows shells, never an escape', () => { + expect(describeRunningCommand('cd C:\\; npm test', 'powershell')).toBe('npm') + }) + + it("honors each Windows shell's own escape character", () => { + expect(describeRunningCommand('echo ^& npm test', 'cmd')).toBe('echo') + expect(describeRunningCommand('echo `; npm test', 'pwsh')).toBe('echo') + }) + + it('reads a single quote in cmd as an ordinary character', () => { + expect(describeRunningCommand("echo 'a & npm test'", 'cmd')).toBe('npm') + }) }) diff --git a/packages/terminal-protocol/src/index.ts b/packages/terminal-protocol/src/index.ts index 78c9817a2ec..511430f73a9 100644 --- a/packages/terminal-protocol/src/index.ts +++ b/packages/terminal-protocol/src/index.ts @@ -273,6 +273,12 @@ export interface TerminalTabState { */ interactive: boolean active: boolean + /** + * The shell running in this terminal, as its program name without a + * directory or `.exe` (`zsh`, `bash`, `pwsh`, `powershell`, `cmd`). Absent + * from shells that predate the field, which all run on macOS. + */ + shell?: string /** * The tmux session attached in this terminal, when one is. Its windows and * panes are tmux's to manage — `panes` lists them; Sim's tab strip stays a @@ -302,28 +308,67 @@ const COMMAND_PREFIX_WORDS = new Set([ /** `NAME=value`, the other thing that can sit in front of the program. */ const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/ +/** What a shell line's quoting looks like, as far as finding its last command needs. */ +interface ShellSyntax { + /** The character that makes the next one literal. */ + escape: string + /** Whether {@link escape} still works inside double quotes. */ + escapesInDoubleQuotes: boolean + quotes: ReadonlySet + /** Windows shells read either slash as a path separator; POSIX ones only `/`. */ + windowsPaths: boolean +} + +const POSIX_SYNTAX: ShellSyntax = { + escape: '\\', + escapesInDoubleQuotes: true, + quotes: new Set(["'", '"']), + windowsPaths: false, +} + +const POWERSHELL_SYNTAX: ShellSyntax = { + escape: '`', + escapesInDoubleQuotes: true, + quotes: new Set(["'", '"']), + windowsPaths: true, +} + +/** cmd has no single quotes, and a caret is literal inside double quotes. */ +const CMD_SYNTAX: ShellSyntax = { + escape: '^', + escapesInDoubleQuotes: false, + quotes: new Set(['"']), + windowsPaths: true, +} + +const SHELL_SYNTAX: ReadonlyMap = new Map([ + ['pwsh', POWERSHELL_SYNTAX], + ['powershell', POWERSHELL_SYNTAX], + ['cmd', CMD_SYNTAX], +]) + /** * The last command in a shell line, ignoring separators inside quotes. A * quote-blind split would cut `claude "a && b"` in half and report `b"` as the * program. */ -function lastCommandSegment(command: string): string { +function lastCommandSegment(command: string, syntax: ShellSyntax): string { let start = 0 - let quote: "'" | '"' | null = null + let quote: string | null = null for (let index = 0; index < command.length; index++) { const char = command[index] if (quote) { - // Only double quotes honor backslash escapes; inside single quotes a - // backslash is a literal character and cannot hide the closing quote. - if (char === '\\' && quote === '"') index++ + // Only double quotes can honor escapes; inside single quotes the escape + // character is literal and cannot hide the closing quote. + if (char === syntax.escape && quote === '"' && syntax.escapesInDoubleQuotes) index++ else if (char === quote) quote = null continue } - if (char === "'" || char === '"') { + if (syntax.quotes.has(char)) { quote = char continue } - if (char === '\\') { + if (char === syntax.escape) { index++ continue } @@ -335,6 +380,12 @@ function lastCommandSegment(command: string): string { return command.slice(start).trim() } +/** A segment's words, each quoted run kept whole so `"C:\Program Files\x.exe"` is one word. */ +function shellWords(segment: string, syntax: ShellSyntax): string[] { + const quoted = [...syntax.quotes].map((quote) => `${quote}[^${quote}]*${quote}`) + return segment.match(new RegExp(`(?:${[...quoted, '\\S'].join('|')})+`, 'g')) ?? [] +} + /** * A short name for whatever is holding a terminal's foreground. * @@ -353,33 +404,47 @@ function lastCommandSegment(command: string): string { * @example * describeRunningCommand('cd /repo && export PATH=/bin && claude "fix it"') // 'claude' * describeRunningCommand('sudo /usr/bin/docker compose up') // 'docker' + * describeRunningCommand('cd C:\repo; C:\tools\node.exe x', 'pwsh') // 'node' */ -export function describeRunningCommand(running: string): string { +export function describeRunningCommand(running: string, shell?: string): string { const line = running.trim() if (!line) return 'a command' - const segment = lastCommandSegment(line) || line - const program = segment - .split(/\s+/) - .filter(Boolean) - .find( - (word) => - !ENVIRONMENT_ASSIGNMENT.test(word) && - !COMMAND_PREFIX_WORDS.has(word) && - !word.startsWith('-') - ) + const syntax = (shell && SHELL_SYNTAX.get(shell)) || POSIX_SYNTAX + const segment = lastCommandSegment(line, syntax) || line + const program = shellWords(segment, syntax).find( + (word) => + !ENVIRONMENT_ASSIGNMENT.test(word) && !COMMAND_PREFIX_WORDS.has(word) && !word.startsWith('-') + ) const label = (program - ? (program - .replace(/^['"]|['"]$/g, '') - .split('/') - .filter(Boolean) - .pop() ?? '') + ? lastPathSegment( + [...program].filter((char) => !syntax.quotes.has(char)).join(''), + syntax.windowsPaths ? /[\\/]/ : '/' + ) : '') || segment return truncate(label, MAX_RUNNING_COMMAND_LABEL, '…') } +/** + * A program's name from its path: the last segment, split on either separator as Windows reads + * both, without `.exe` in any case. `C:\Windows\System32\cmd.EXE` is `cmd`. + */ +export function programName(path: string): string { + return lastPathSegment(path, /[\\/]/) +} + +function lastPathSegment(path: string, separator: string | RegExp): string { + return ( + path + .split(separator) + .filter(Boolean) + .pop() + ?.replace(/\.exe$/i, '') ?? '' + ) +} + /** One tmux pane, as reported by the `panes` operation. */ export interface TerminalPaneState { /** tmux target (`session:window.pane`), usable as the `pane` argument. */ diff --git a/packages/testing/src/mocks/lib-desktop.mock.ts b/packages/testing/src/mocks/lib-desktop.mock.ts index 3fba8d7dfac..c829afec644 100644 --- a/packages/testing/src/mocks/lib-desktop.mock.ts +++ b/packages/testing/src/mocks/lib-desktop.mock.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' interface MockDesktopBridge { version?: string + platform?: string updates?: unknown } @@ -21,7 +22,8 @@ function isDesktop(): boolean { * `getDesktopBridge() != null` (device switches read as enabled, the real unread default). The * real check is `!== undefined`; `null` is also treated as "no bridge" because several local * stubs returned `null` for the browser case. - * - `getDesktopShellVersion` returns `bridge?.version`, `getDesktopUpdates` returns `bridge?.updates`. + * - `getDesktopShellVersion` returns `bridge?.version`, `getDesktopUpdates` returns `bridge?.updates`, + * `getDesktopPlatform` returns `bridge?.platform`, defaulting a bridge without one to `'darwin'`. * - `mockSubscribeDesktopPreferences` returns a no-op unsubscribe; `mockSetDesktopPreferencesSnapshot` * is a no-op; `mockGetDesktopChatCapabilities` resolves `{}` (the web result). * @@ -50,6 +52,10 @@ export const libDesktopMockFns = { mockGetDesktopUpdates: vi.fn( (): unknown => (mockGetDesktopBridge() as MockDesktopBridge | undefined)?.updates ), + mockGetDesktopPlatform: vi.fn((): string | undefined => { + const bridge = mockGetDesktopBridge() as MockDesktopBridge | null | undefined + return bridge ? (bridge.platform ?? 'darwin') : undefined + }), mockGetDesktopChatCapabilities: vi.fn(async (_scopeId: string): Promise => ({})), } @@ -75,5 +81,6 @@ export const libDesktopMock = { isTerminalEnabled: libDesktopMockFns.mockIsTerminalEnabled, getDesktopShellVersion: libDesktopMockFns.mockGetDesktopShellVersion, getDesktopUpdates: libDesktopMockFns.mockGetDesktopUpdates, + getDesktopPlatform: libDesktopMockFns.mockGetDesktopPlatform, getDesktopChatCapabilities: libDesktopMockFns.mockGetDesktopChatCapabilities, } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0e83ba85201..9993a845890 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -66,12 +66,10 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/demo-requests/route.ts', // Input-less session-bound GET: nothing to validate; response is // contract-typed via `satisfies InvitationDetails` in the route. - // Public updater feed: input-less GET, session-less, returns YAML (not JSON), - // so it can't be JSON-contract-bound. Wrapped in withRouteHandler. + // Public updater feeds (macOS and Windows): input-less, session-less GETs + // returning YAML, so they can't be JSON-contract-bound. 'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts', - // Public updater download redirect: input-less GET, session-less, whose only - // response is a 302 to a GitHub release asset. Wrapped in withRouteHandler. - 'apps/sim/app/api/desktop/update/download/route.ts', + 'apps/sim/app/api/desktop/update/latest.yml/route.ts', 'apps/sim/app/api/invitations/route.ts', 'apps/sim/app/api/logs/export/route.ts', 'apps/sim/app/api/tools/docusign/route.ts', diff --git a/scripts/check-desktop-ipc-contract.ts b/scripts/check-desktop-ipc-contract.ts index f97a781f94c..0cfb4f6de3c 100644 --- a/scripts/check-desktop-ipc-contract.ts +++ b/scripts/check-desktop-ipc-contract.ts @@ -17,6 +17,10 @@ * `gate` and `requires` values agree, unless the outlier carries an * explicit acknowledgment. A surface toggle that a new channel forgets is * invisible in review — the channel simply works when it should not. + * 3. A channel the preload exposes only inside a `...(features. ? …)` + * block declares the same `platformFeature`, and an ungated call declares + * none. Otherwise a platform that omits the bridge method still registers + * the handler, or a platform that exposes the method finds nothing behind it. * * Deviations are legitimate and common: a channel that READS or RESETS a * surface's settings must keep working while the surface is off, or the user @@ -83,6 +87,8 @@ interface ChannelDecl { line: number /** The channel's own `deviationReason`, or null when it declares none. */ deviationReason: string | null + /** The `DesktopFeatures` key the channel is registered under, or '' for every platform. */ + platformFeature: string } /** The handler table is one `'channel': {` entry per line at a fixed depth. */ @@ -104,6 +110,7 @@ function parseChannelTable(source: string): ChannelDecl[] { requires: /requires:\s*'([^']+)'/.exec(body)?.[1] ?? '', line: i + 1, deviationReason: reason ? (reason[1] ?? reason[2] ?? '').trim() : null, + platformFeature: /platformFeature:\s*'([^']+)'/.exec(body)?.[1] ?? '', }) } return decls @@ -157,8 +164,11 @@ async function channelsPushedFromMain(): Promise> { return pushed } -function channelsFromPreload(source: string, methods: string): Set { - const constants = channelConstants(source) +function channelsFromPreload( + source: string, + methods: string, + constants = channelConstants(source) +): Set { const found = new Set() const pattern = new RegExp( `ipcRenderer\\.(?:${methods})\\(\\s*(?:'([^']+)'|([A-Za-z_$][\\w$]*))`, @@ -171,6 +181,41 @@ function channelsFromPreload(source: string, methods: string): Set { return found } +interface PreloadExposure { + /** Channels called inside a `...(features. ? { … } : {})` spread, keyed to that feature. */ + gated: Map + /** Channels called anywhere outside such a spread, which every platform exposes. */ + ungated: Set +} + +/** + * Which channels the preload exposes behind a feature spread and which it exposes everywhere. A + * spread's extent is its balanced parens; a channel called both inside and outside one is ungated. + */ +function preloadExposure(source: string): PreloadExposure { + const constants = channelConstants(source) + const gated = new Map() + let ungatedSource = '' + let cursor = 0 + for (const match of source.matchAll(/\.\.\.\(features\.(\w+)/g)) { + const start = match.index + '...'.length + if (start < cursor) continue + let depth = 0 + let end = start + for (; end < source.length; end++) { + if (source[end] === '(') depth++ + else if (source[end] === ')' && --depth === 0) break + } + for (const name of channelsFromPreload(source.slice(start, end), 'invoke|send', constants)) { + gated.set(name, match[1]) + } + ungatedSource += source.slice(cursor, match.index) + cursor = end + 1 + } + ungatedSource += source.slice(cursor) + return { gated, ungated: channelsFromPreload(ungatedSource, 'invoke|send', constants) } +} + async function main(): Promise { const [ipcSource, ...preloadSources] = await Promise.all([ readFile(IPC_SOURCE_PATH, 'utf8'), @@ -208,9 +253,14 @@ async function main(): Promise { const called = new Set() const subscribed = new Set() + const gatedByFeature = new Map() + const ungated = new Set() for (const source of preloadSources) { for (const name of channelsFromPreload(source, 'invoke|send')) called.add(name) for (const name of channelsFromPreload(source, 'on|once')) subscribed.add(name) + const exposure = preloadExposure(source) + for (const [name, feature] of exposure.gated) gatedByFeature.set(name, feature) + for (const name of exposure.ungated) ungated.add(name) } const declaredNames = new Set(declared.map((channel) => channel.name)) const preloadLabel = 'apps/desktop/src/preload' @@ -233,6 +283,18 @@ async function main(): Promise { ) } + for (const channel of declared) { + if (!called.has(channel.name)) continue + const exposedUnder = ungated.has(channel.name) ? '' : (gatedByFeature.get(channel.name) ?? '') + if (channel.platformFeature === exposedUnder) continue + const shown = (feature: string) => (feature === '' ? 'every platform' : `features.${feature}`) + failures.push( + `${relative(ROOT, IPC_SOURCE_PATH)}:${channel.line}: '${channel.name}' is registered for ` + + `${shown(channel.platformFeature)}, but the preload exposes it for ${shown(exposedUnder)}. ` + + 'Match its `platformFeature` to the preload so the handler and the bridge method ship together.' + ) + } + const pushed = await channelsPushedFromMain() for (const name of subscribed) { if (declaredNames.has(name) || pushed.has(name)) continue diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 1199b592d45..cc10e967238 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -8,8 +8,6 @@ "apps/desktop/src/main/browser-agent/driver.ts#showDownloadsMenu", "apps/desktop/src/main/browser-agent/known-sessions.ts#normalizePublicHostname", "apps/desktop/src/main/browser-agent/panel.ts#getActivePanelScopeId", - "apps/desktop/src/main/browser-agent/session.ts#BrowserShortcut", - "apps/desktop/src/main/browser-agent/session.ts#browserShortcutForInput", "apps/desktop/src/main/browser-agent/session.ts#setBrowserTheme", "apps/desktop/src/main/browser-credentials/index.ts#credentialVault", "apps/desktop/src/main/browser-credentials/os-auth.ts#SecretOperation",