diff --git a/.github/scripts/ci-plan.gql b/.github/scripts/ci-plan.gql index b2a0e6c184f..6a55e49a4a2 100644 --- a/.github/scripts/ci-plan.gql +++ b/.github/scripts/ci-plan.gql @@ -1,22 +1,56 @@ -query($base: String!, $head: String!) { - packages { items { name path } } - affectedTasks(base: $base, head: $head, tasks: [ - "test", "build", "type-check", "ci:integration", "ci:desktop-live", - "ci:e2e-scim", "ci:e2e-cli", "ci:e2e-stop-after", "ci:e2e-desktop-inbox", - "ci:e2e-browser-extension", "ci:e2e-project-files", "ci:e2e-mobile", "ci:e2e-seo" - ]) { +query ($base: String!, $head: String!) { + packages { + items { + name + path + } + } + affectedTasks( + base: $base + head: $head + tasks: [ + "test" + "build" + "type-check" + "ci:integration" + "ci:desktop-live" + "ci:e2e-scim" + "ci:e2e-cli" + "ci:e2e-stop-after" + "ci:e2e-desktop-inbox" + "ci:e2e-browser-extension" + "ci:e2e-project-files" + "ci:e2e-mobile" + "ci:e2e-seo" + ] + ) { items { name fullName - package { name } + package { + name + } reason { __typename - ... on TaskFileChanged { filePath } - ... on TaskGlobalFileChanged { filePath } - ... on TaskGlobalDepsChanged { filePath } - ... on TaskDependencyTaskChanged { taskName packageName } - ... on TaskPackageDependencyChanged { packageName } - ... on TaskAllChanged { description } + ... on TaskFileChanged { + filePath + } + ... on TaskGlobalFileChanged { + filePath + } + ... on TaskGlobalDepsChanged { + filePath + } + ... on TaskDependencyTaskChanged { + taskName + packageName + } + ... on TaskPackageDependencyChanged { + packageName + } + ... on TaskAllChanged { + description + } } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75277ba3f97..9f91c6acec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1260,8 +1260,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 }} @@ -1275,6 +1279,55 @@ 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 that no + # other job needs, so a Windows failure never blocks a macOS release; the + # update feed treats a release without latest.yml as having no Windows build. + 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 @@ -1285,15 +1338,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 @@ -1355,11 +1413,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 }} @@ -1374,6 +1440,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. @@ -1381,7 +1472,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 b34719e1bb1..bc4a0a6480c 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -260,3 +260,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' + 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 f8d7da153c0..c129aa2cc35 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: write @@ -62,6 +83,7 @@ permissions: jobs: build: name: build + if: ${{ inputs.platform != 'windows' }} runs-on: macos-26 timeout-minutes: 60 steps: @@ -479,3 +501,362 @@ jobs: apps/desktop/release/*.blockmap 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. + build-windows: + name: build-windows + if: ${{ inputs.platform == 'windows' }} + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: write + id-token: write + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.sha }} + + # Mirrors the macOS job's channel identity. SLUG becomes the 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: + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + *-dev.*|*-alpha.*) + 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; 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; 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" + } >> "$GITHUB_OUTPUT" + echo "Building $NAME ($APP_ID, $SLUG) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}" + + - name: Validate release source + env: + PUBLISH: ${{ inputs.publish }} + SIGN: ${{ inputs.sign }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + VERSION: ${{ inputs.version }} + 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 [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$TOKEN_KIND" != stable ]; then + echo "::error::Manual desktop releases must use a stable source-repository tag." + exit 1 + fi + if [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then + echo "::error::Desktop releases must be signed before publication." + exit 1 + fi + if [ "$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 [ "$TOKEN_KIND" = stable ]; then + TAG_COMMIT="$(git rev-parse "refs/tags/${VERSION}^{commit}")" + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::Requested tag $VERSION points to $TAG_COMMIT, but the checkout is $HEAD_COMMIT." + exit 1 + fi + fi + + - name: Validate release authentication + if: ${{ inputs.publish }} + env: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + run: | + if [ "$TOKEN_KIND" = prerelease ] && [ -z "$DESKTOP_RELEASE_TOKEN" ]; then + echo "::error::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY." + exit 1 + fi + + - 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 binaries + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: electron-cache-${{ runner.os }}-${{ hashFiles('apps/desktop/package.json') }} + + - name: Install dependencies + env: + HUSKY: '0' + run: bun install --frozen-lockfile + + - name: Inject release version + env: + VERSION: ${{ inputs.version }} + run: | + SEMVER="${VERSION#v}" + cd apps/desktop + bun pm pkg set version="$SEMVER" + cd ../.. + INJECTED="$(node -p "require('./apps/desktop/package.json').version")" + if [ "$INJECTED" != "$SEMVER" ]; then + echo "Version injection mismatch: wanted $SEMVER got $INJECTED" >&2 + exit 1 + fi + + # Lint and type-check are platform-independent and already run in the + # macOS job; the unit suite runs here for the Windows code paths. + - name: Verify desktop source + run: bun run --cwd apps/desktop test + + - name: Bundle main and preload + working-directory: apps/desktop + env: + SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} + run: bun run build + + - name: 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 if any file that should be signed is + # not. 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: ${{ steps.channel.outputs.name }} + APP_ID: ${{ steps.channel.outputs.app_id }} + SLUG: ${{ steps.channel.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: ${{ steps.channel.outputs.name }} + APP_ID: ${{ steps.channel.outputs.app_id }} + SLUG: ${{ steps.channel.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: ${{ steps.channel.outputs.name }} + SLUG: ${{ steps.channel.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: ${{ steps.channel.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: ${{ steps.channel.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 release + if: ${{ inputs.publish }} + env: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }} + SOURCE_RELEASE_TOKEN: ${{ github.token }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + VERSION: ${{ inputs.version }} + run: | + case "$TOKEN_KIND" in + prerelease) GH_TOKEN="$DESKTOP_RELEASE_TOKEN" ;; + stable) GH_TOKEN="$SOURCE_RELEASE_TOKEN" ;; + *) + echo "::error::Unknown desktop release token kind: $TOKEN_KIND" + exit 1 ;; + esac + if [ -z "$GH_TOKEN" ]; then + echo "::error::No GitHub token is available to publish to $RELEASE_REPOSITORY." + exit 1 + fi + export GH_TOKEN + if ! RELEASE_ID="$( + gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" \ + --json databaseId --jq '.databaseId' + )"; then + echo "::error::Release $VERSION does not exist in $RELEASE_REPOSITORY." + exit 1 + fi + if ! [[ "$RELEASE_ID" =~ ^[0-9]+$ ]]; then + echo "::error::Release $VERSION returned an invalid database ID." + exit 1 + fi + RELEASE_JSON="$(gh api "repos/${RELEASE_REPOSITORY}/releases/${RELEASE_ID}")" + SEMVER="${VERSION#v}" + # latest.yml goes last: the feed only offers a Windows build once its + # manifest exists, so it must never point at a missing installer. + ARTIFACTS=( + "apps/desktop/release/Sim-${SEMVER}-x64.exe" + "apps/desktop/release/Sim-${SEMVER}-x64.exe.blockmap" + "apps/desktop/release/latest.yml" + ) + upload_or_verify() { + local ARTIFACT="$1" + local NAME SIZE DIGEST REMOTE REMOTE_SIZE REMOTE_DIGEST + NAME="$(basename "$ARTIFACT")" + SIZE="$(stat -c%s "$ARTIFACT")" + DIGEST="sha256:$(sha256sum "$ARTIFACT" | awk '{print $1}')" + REMOTE="$(jq -c --arg name "$NAME" '.assets[] | select(.name == $name)' <<< "$RELEASE_JSON")" + if [ -n "$REMOTE" ]; then + REMOTE_SIZE="$(jq -r '.size' <<< "$REMOTE")" + REMOTE_DIGEST="$(jq -r '.digest // empty' <<< "$REMOTE")" + if [ "$REMOTE_SIZE" != "$SIZE" ] || [ "$REMOTE_DIGEST" != "$DIGEST" ]; then + echo "::error::Existing release asset $NAME does not match this build." + exit 1 + fi + echo "Verified existing release asset $NAME; skipping upload." + return + fi + gh release upload "$VERSION" "$ARTIFACT" --repo "$RELEASE_REPOSITORY" + } + for ARTIFACT in "${ARTIFACTS[@]}"; do + upload_or_verify "$ARTIFACT" + done + + - name: Upload artifacts to the workflow run + if: ${{ !inputs.publish }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: sim-desktop-windows-${{ inputs.version }} + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.blockmap + apps/desktop/release/latest.yml + retention-days: 7 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 7f9415e3fdc..951a803724f 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. @@ -192,15 +192,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. 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. + +## 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 on anything unsigned. `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. A confirmed restart runs the installer silently and relaunches. 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. No job waits on the Windows calls, so a Windows failure never blocks the macOS release, 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). Stop sends Ctrl-C, then `taskkill /T /F` to each of the shell's child processes. 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 and importing from other browsers. + +| 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/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 c9219a003db..d44deddbf10 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] @@ -96,6 +98,31 @@ 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; also sign the native addons (node-pty) so + # every binary Sim loads carries the publisher's Authenticode 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 92826c3aec4..024c698c6fb 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:*", @@ -46,6 +46,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.61.1", diff --git a/apps/desktop/scripts/build-native.ts b/apps/desktop/scripts/build-native.ts index 8980683f31f..648b4dd8939 100644 --- a/apps/desktop/scripts/build-native.ts +++ b/apps/desktop/scripts/build-native.ts @@ -4,6 +4,12 @@ import { dirname, join } from 'node:path' import { createLogger } from '@sim/logger' const logger = createLogger('DesktopNativeBuild') +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 73dff48279f..e0c6c7d2084 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -28,11 +28,12 @@ if (bakedDefaultOrigin) { console.log(`• Baking default server 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') +console.log(`• Selecting desktop icon: ${identity.icon} / ${identity.windowsIcon}`) /** Bundle the native helper; packaged builds sign it with the desktop distribution identity. */ function compileComputerUse(): void { 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..417245153b9 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,9 @@ * 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 + * (`conpty.node` plus its bundled `conpty/` helper) instead of `pty.node`. */ import { execFileSync } from 'node:child_process' import { createHash, timingSafeEqual } from 'node:crypto' @@ -24,7 +28,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 +35,19 @@ import { getErrorMessage } from '@sim/utils/errors' const logger = createLogger('DesktopPtyPrebuilds') -const REQUIRED_ARCHES = ['darwin-arm64', 'darwin-x64'] as const +interface RequiredPrebuild { + arch: string + /** The binary whose presence proves the package was fully extracted. */ + binary: string +} + +const REQUIRED_PREBUILDS: Partial> = { + darwin: [ + { arch: 'darwin-arm64', binary: 'pty.node' }, + { arch: 'darwin-x64', binary: 'pty.node' }, + ], + win32: [{ arch: 'win32-x64', binary: 'conpty.node' }], +} const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))) const workspaceRoot = dirname(dirname(desktopDir)) @@ -92,16 +107,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 +126,34 @@ 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, binary } of required) { + const binaryPath = join(packageDir(arch), 'prebuilds', arch, binary) + if (existsSync(binaryPath)) { 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`) + if (!existsSync(binaryPath)) { + throw new Error(`Downloaded @lydell/node-pty-${arch} but ${binary} 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..c743a45d9bd 100644 --- a/apps/desktop/scripts/install-local.ts +++ b/apps/desktop/scripts/install-local.ts @@ -22,32 +22,33 @@ 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 { CHANNEL_IDENTITIES, type DesktopChannelIdentity } from '@/shared/channel-identity' +import { DEV, LOCAL, STAGING } from './channels' -interface ChannelIdentity { - /** Display + bundle name; also the userData directory name. */ - name: string - appId: string +if (process.platform !== 'darwin') { + console.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 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..21355dcba4e 100644 --- a/apps/desktop/scripts/package-share.ts +++ b/apps/desktop/scripts/package-share.ts @@ -37,6 +37,13 @@ import { spawnSync } from 'node:child_process' import { rmSync } from 'node:fs' import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels' +if (process.platform !== 'darwin') { + console.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/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-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..7c904483202 100644 --- a/apps/desktop/src/main/browser-credentials/vault.ts +++ b/apps/desktop/src/main/browser-credentials/vault.ts @@ -14,8 +14,8 @@ 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), written atomically with restrictive + * permissions. 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 3148c992577..3cd50f9ce22 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..0f4cb74559b 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -311,20 +311,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-')) 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 951bc151f6c..993c23a22ca 100644 --- a/apps/desktop/src/main/downloads.test.ts +++ b/apps/desktop/src/main/downloads.test.ts @@ -45,7 +45,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 a26582f379b..a0c518ef6ae 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 { 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,20 @@ 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) + return platform === 'win32' ? toWin32SafeFileName(cleaned) : cleaned } /** @@ -131,10 +137,20 @@ export async function uniqueDownloadPath( return null } +/** 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 + win.once('focus', () => { + 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) => { @@ -148,6 +164,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.ts b/apps/desktop/src/main/handoff.ts index caeb3a4b612..b48e12f3a40 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -5,9 +5,9 @@ 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 { showShellDialog } from '@/main/dialogs' import type { EventRecorder } from '@/main/observability' +import { bringWindowToFront } from '@/main/shell-window' const logger = createLogger('DesktopHandoff') @@ -523,9 +523,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { try { await win.loadURL(`${origin}/workspace`) } 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/index.ts b/apps/desktop/src/main/index.ts index 56c4261fe10..1a489ba3c09 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,7 +57,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, @@ -106,14 +105,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') @@ -128,13 +130,6 @@ function reportHandoffFailure(error: unknown): void { logger.error('Sign-in handoff failed', { error: getErrorMessage(error) }) } -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() @@ -188,7 +183,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( @@ -258,6 +254,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() @@ -303,14 +309,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( @@ -506,6 +509,7 @@ function main(): void { const win = createMainWindow({ config, events, + icon: unpackagedWindowIcon, appOrigin, partition: appPartition(origin), preloadPath, @@ -793,13 +797,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) => { @@ -862,7 +874,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(), @@ -1141,7 +1153,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 aeece41f983..0faafe51f6e 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -305,6 +305,7 @@ describe('registerIpcHandlers', () => { deps = { appOrigin: () => APP, getExecutorDevice: () => null, + platform: 'darwin', allowHttpLocalhost: () => false, accountDataAvailable: () => true, isLocalPageUrl, @@ -368,6 +369,31 @@ 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-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') @@ -389,8 +415,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 ebd34d86e1f..18c2b344e40 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -98,6 +98,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') @@ -394,6 +395,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 } /** @@ -421,6 +424,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. */ @@ -710,6 +719,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, @@ -717,6 +727,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(), @@ -724,6 +735,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:set-enabled': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: null, @@ -736,6 +748,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:request-permission': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: null, @@ -748,6 +761,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() ?? [], @@ -755,6 +769,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:revoke-app': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: undefined, @@ -767,6 +782,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)) @@ -777,6 +793,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) => { @@ -966,6 +983,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'terminal-themes:list-profiles': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'terminalThemes', requires: 'terminal', denied: [], handler: () => listTerminalThemeProfiles(), @@ -973,6 +991,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'terminal-themes:select-profile': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'terminalThemes', requires: 'terminal', needsUserActivation: true, denied: null, @@ -1460,6 +1479,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:list-profiles': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', denied: [], handler: () => listChromeImportProfiles(), @@ -1467,6 +1487,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' }, @@ -1483,6 +1504,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:all': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', needsUserActivation: true, denied: { @@ -1647,6 +1669,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', @@ -1669,6 +1692,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, @@ -1699,6 +1723,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', @@ -2136,7 +2161,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..2e4233b8798 100644 --- a/apps/desktop/src/main/local-file-permissions.ts +++ b/apps/desktop/src/main/local-file-permissions.ts @@ -7,6 +7,7 @@ import { showShellDialog } from '@/main/dialogs' import type { LocalFileAuthorization } from '@/main/local-files' import type { LocalFileAccess, LocalFilesystemService } from '@/main/local-filesystem' import { openNativeFile } from '@/main/native-directory' +import { resolveWin32NativePath } from '@/main/win32-path' const MAX_PENDING_REQUESTS = 32 @@ -25,9 +26,18 @@ interface PendingFolderDecision { decision: Promise } -function nativePath(value: unknown): string { +/** + * Lexically resolves a model-supplied path. Runs before any filesystem call, + * which on Windows is what keeps a UNC path from reaching the network. + */ +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 if (!isAbsolute(path)) throw new Error('Use an absolute path or ~/ path.') 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..b7423e4c048 100644 --- a/apps/desktop/src/main/local-files.ts +++ b/apps/desktop/src/main/local-files.ts @@ -11,8 +11,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 @@ -43,11 +43,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 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..f69879433b0 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -4,7 +4,6 @@ import { basename, isAbsolute, relative, resolve, sep } from 'node:path' import type { LocalFilesystemData, LocalFilesystemEntry, - LocalFilesystemEntryKind, LocalFilesystemGrepMatch, LocalFilesystemMount, LocalFilesystemResponse, @@ -33,11 +32,14 @@ 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 { 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 +97,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 +114,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 +128,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('/') @@ -364,10 +334,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 +623,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) @@ -675,12 +647,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.' @@ -785,11 +752,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', @@ -976,33 +941,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 +1043,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.') } @@ -1130,7 +1099,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 +1164,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 +1454,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/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 c9334dab154..9cf7d40f8b0 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -37,7 +37,7 @@ import { isResourceTabSelectionShortcut, resourceTabTargetIndex, } from '@/main/resource-shortcuts' -import { 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 { @@ -160,22 +160,12 @@ 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, -} - /** * One tool call's Stop: remembered if it arrives before the call's command starts, and handed to * whatever is running once it does. @@ -602,7 +592,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 ( @@ -667,13 +657,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) @@ -966,25 +956,31 @@ 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. Escalation is bound to that command's own processes, + * read only once Ctrl-C has failed (on Windows the lookup starts PowerShell) and while it still + * holds the foreground; each signal goes only to those of them still running while the same call + * does, so a command the user starts meanwhile is never hit. */ private async stopCommand(session: TerminalSession, toolCallId: string): Promise { - if (session.agentCommandToolCallId !== toolCallId) return - const groups = this.options.processGroups ?? OS_PROCESS_GROUPS - const pgid = await groups.foreground(session.pid) - if (session.agentCommandToolCallId !== toolCallId) return + const startedAt = session.agentCommandStartedAt + if (session.agentCommandToolCallId !== toolCallId || startedAt === null) return + const groups = this.options.processGroups ?? osProcessGroups session.kill('SIGINT') - for (const escalation of ['SIGTERM', 'SIGKILL'] as const) { + let command: number[] | undefined + for (const escalation of groups.escalations) { if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return - if (pgid === null || (await groups.foreground(session.pid)) !== pgid) return + const remaining = + command === undefined + ? await groups.foreground(session.pid, startedAt) + : 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) } } @@ -1394,6 +1390,7 @@ export class TerminalService { if (!session.hasShellIntegration) { await this.awaitShellStartup(session, latch) } + await session.waitForPromptDrawn(latch.signal) if (session.isBusy) { throw new TerminalError( 'BUSY', diff --git a/apps/desktop/src/main/terminal/process-group.test.ts b/apps/desktop/src/main/terminal/process-group.test.ts new file mode 100644 index 00000000000..4f8312e3566 --- /dev/null +++ b/apps/desktop/src/main/terminal/process-group.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { type WindowsProcess, windowsProcessGroups } from '@/main/terminal/process-group' + +const SHELL_PID = 100 +const COMMAND_TYPED_AT = 1_000_000 + +async function foreground(table: WindowsProcess[]): Promise { + const ids = await windowsProcessGroups(async () => table).foreground(SHELL_PID, COMMAND_TYPED_AT) + return [...ids].sort() +} + +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: COMMAND_TYPED_AT - 90_000 }, + { 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]) + }) +}) diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index 6e5a4e10a87..186c5e9652e 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -1,34 +1,193 @@ /** - * 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. They are found among the shell's descendants rather than its + * children, since a launcher can stand between the pty and the real shell (Git for Windows' + * `bin\bash.exe` starts `usr\bin\bash.exe`). Processes from before then (a server the user started + * in the background earlier, the real shell behind a launcher) are someone else's and are 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. */ 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 the lookup failed. + */ + foreground(shellPid: number, startedAt: number): 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[] +} + +const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { + foreground: async (shellPid) => { + const pgid = await readForegroundProcessGroup(shellPid) + return pgid === null ? [] : [pgid] + }, + stillRunning: async (shellPid, pgids) => { + const foreground = await readForegroundProcessGroup(shellPid) + return pgids.filter((pgid) => pgid === foreground) + }, + signal: (pgids, signal) => { + for (const pgid of pgids) signalProcessGroup(pgid, signal) + }, + escalations: ['SIGTERM', 'SIGKILL'], +} + +/** A Windows process, as the process table reports it. */ +export interface WindowsProcess { + pid: number + parentPid: number + /** Epoch milliseconds. */ + createdAt: number +} + +/** + * The outermost of the shell's descendants created at or after `startedAt`: the processes a + * command typed then started, each standing for its whole tree. + */ +function commandRoots(table: WindowsProcess[], shellPid: number, startedAt: number): number[] { + 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([shellPid]) + const pending = [...(children.get(shellPid) ?? [])] + for (let entry = pending.pop(); entry; entry = pending.pop()) { + if (visited.has(entry.pid)) continue + visited.add(entry.pid) + if (entry.createdAt >= startedAt) roots.push(entry.pid) + else pending.push(...(children.get(entry.pid) ?? [])) + } + return roots +} + +/** + * 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 windowsProcessGroups( + readProcesses: () => Promise +): TerminalProcessGroups { + return { + foreground: async (shellPid, startedAt) => + commandRoots(await readProcesses(), shellPid, startedAt), + stillRunning: async (_shellPid, pids) => pids.filter(isRunning), + signal: (pids) => { + for (const pid of pids) killProcessTree(pid) + }, + escalations: ['SIGTERM'], + } +} + +/** 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`. Null when it cannot be read, or when the - * shell itself holds the foreground (it is sitting at its prompt). + * shell itself holds the foreground. */ -export function readForegroundProcessGroup(shellPid: number): Promise { - if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(null) +async function readForegroundProcessGroup(shellPid: number): Promise { + if (!isProcessId(shellPid)) return null + const stdout = await readOutput('ps', ['-o', 'tpgid=', '-p', String(shellPid)], LOOKUP_TIMEOUT_MS) + const pgid = Number.parseInt(stdout?.trim() ?? '', 10) + return isProcessId(pgid) && pgid !== shellPid ? pgid : null +} + +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 stdout = 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 + ) + return (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 +} + +/** A short-lived command's 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(null) return } let stdout = '' let settled = false - const finish = (value: number | null) => { + const finish = (value: string | null) => { if (settled) return settled = true clearTimeout(timer) @@ -37,26 +196,11 @@ export function readForegroundProcessGroup(shellPid: number): Promise { child.kill('SIGKILL') finish(null) - }, LOOKUP_TIMEOUT_MS) + }, timeoutMs) child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) child.on('error', () => finish(null)) - child.on('close', () => { - const pgid = Number.parseInt(stdout.trim(), 10) - finish(Number.isInteger(pgid) && pgid > 0 && pgid !== shellPid ? pgid : null) - }) + child.on('close', () => finish(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..1e7837ec1c7 --- /dev/null +++ b/apps/desktop/src/main/terminal/pty.ts @@ -0,0 +1,100 @@ +/** + * 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. node-pty there cannot send a signal + * either, only end the shell. + */ +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 + /** Sends `signal` to the shell; on Windows, which has no signals, ends it. */ + kill(signal?: NodeJS.Signals): 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: (signal) => (windows ? pty.kill() : pty.kill(signal)), + } +} diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts index 22c91489830..c7aab37873f 100644 --- a/apps/desktop/src/main/terminal/registry.ts +++ b/apps/desktop/src/main/terminal/registry.ts @@ -312,12 +312,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 6ff6a0a53af..24989c18bc3 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -177,10 +177,14 @@ vi.mock('@/main/terminal/session', async () => { get agentCommandToolCallId() { return state.toolCallId }, + get agentCommandStartedAt() { + return state.toolCallId === null ? null : 0 + }, kill: vi.fn((signal: string) => { if (signal === 'SIGINT' && state.interruptible) finishRun(130) }), waitForShellIntegration: async () => {}, + waitForPromptDrawn: async () => {}, /** Stands in for the user running `exit` or pressing Ctrl-D. */ exit: () => { state.disposed = true @@ -198,6 +202,7 @@ vi.mock('@/main/terminal/session', async () => { return state.cwd }, shell: 'zsh', + shellProgram: 'zsh', get foreground() { return state.busy ? 'sleep 1' : null }, @@ -331,7 +336,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) }) @@ -481,11 +486,15 @@ 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 + /** What holds the terminal's foreground as the OS would report it at each read. */ + let foreground: number[] = [COMMAND_GROUP] const processGroups = { - foreground: vi.fn(async (_shellPid: number) => foreground), - signal: vi.fn((_pgid: number, _signal: NodeJS.Signals) => {}), + foreground: vi.fn(async (_shellPid: number, _startedAt: number) => foreground), + 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[], } const terminal = new TerminalService({ loadCwd: () => '/tmp', processGroups }) const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) @@ -495,8 +504,8 @@ describe('stopping a tool call', () => { terminal, session, processGroups, - setForeground: (pgid: number | null) => { - foreground = pgid + setForeground: (ids: number[]) => { + foreground = ids }, } } @@ -520,6 +529,7 @@ describe('stopping a tool call', () => { expect(session.kill).toHaveBeenCalledWith('SIGINT') await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 130 } }) + expect(processGroups.foreground).not.toHaveBeenCalled() expect(processGroups.signal).not.toHaveBeenCalled() }) @@ -531,19 +541,66 @@ 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 } }) }) + 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) + processGroups.signal.mockImplementation(() => setForeground([9999])) const { running } = await startRun(terminal, session, 'call-replaced') - session.kill.mockImplementation(() => setForeground(9999)) await terminal.cancelTool('call-replaced') + 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 }) diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index ce2725af3ae..3f2902f4557 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -11,23 +11,34 @@ const ptyStub = vi.hoisted(() => ({ dataHandler: null as ((data: string) => void) | null, exitHandler: null as (() => void) | null, writes: [] as string[], + args: [] as string[], + /** node-pty on Windows throws when `kill` is given a signal. */ + signalsSupported: true, + ended: false, })) 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: (signal?: string) => { + if (signal && !ptyStub.signalsSupported) + throw new Error('Signals not supported on windows.') + ptyStub.ended = true + }, + pause: vi.fn(), + resume: vi.fn(), + } + }, })) vi.mock('@/main/terminal/shell-integration', async (importOriginal) => { @@ -40,6 +51,8 @@ afterEach(() => { ptyStub.dataHandler = null ptyStub.exitHandler = null ptyStub.writes.length = 0 + ptyStub.signalsSupported = true + ptyStub.ended = false }) describe('toInputChunks', () => { @@ -165,6 +178,61 @@ describe('TerminalSession command lifecycle', () => { } }) + 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("never reads an interrupted command's ^C echo as a prompt", async () => { + 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') + session.kill('SIGINT') + 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 +289,187 @@ 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('ends the shell for a terminating signal, which node-pty refuses to send there', () => { + ptyStub.signalsSupported = false + const session = sessionOn('win32') + try { + session.kill('SIGTERM') + expect(ptyStub.ended).toBe(true) + } 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..ade9f78774b 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,15 @@ 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, + shellKind, } from '@/main/terminal/shell-integration' +import { resolveShellPath } from '@/main/terminal/shell-path' const logger = createLogger('DesktopTerminalSession') @@ -245,6 +246,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 +261,11 @@ interface PendingCommand { /** Rolling tail kept once {@link MAX_CAPTURE_CHARS} is exceeded. */ overflow: string capturing: boolean + /** + * Set once Ctrl-C was sent. 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. + */ + interrupted: boolean timer: NodeJS.Timeout resolve(result: TerminalRunResult): void } @@ -294,16 +303,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 /** @@ -340,15 +372,21 @@ 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 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 +402,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 +410,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 +424,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 +518,11 @@ export class TerminalSession { return this.agentToolCallId } + /** When the agent's still-running command was typed into the shell, in epoch milliseconds. */ + get agentCommandStartedAt(): number | null { + return this.agentCommandSentAt + } + /** * 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 +530,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 +547,7 @@ export class TerminalSession { running: this.foregroundCommand === null ? null : this.foregroundCommand.trim() || 'command', interactive: this.altScreen, active, + shell: this.shellProgram, } } @@ -561,6 +607,31 @@ 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() } @@ -639,6 +710,7 @@ export class TerminalSession { // 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') { + if (this.pendingCommand) this.pendingCommand.interrupted = true this.write('\u0003') return } @@ -671,12 +743,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 }) @@ -859,7 +933,13 @@ export class TerminalSession { this.notifyReadinessWaiters() } break + case 'prompt-end': + this.reportsPromptEnd = true + this.promptDrawn = true + this.notifyReadinessWaiters() + break case 'prompt-start': + this.promptDrawn = false if (!this.shellIntegration) { this.shellIntegration = true this.emitState() @@ -870,6 +950,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 +1033,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 +1144,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..2829dcff803 100644 --- a/apps/desktop/src/main/terminal/shell-integration.test.ts +++ b/apps/desktop/src/main/terminal/shell-integration.test.ts @@ -73,6 +73,14 @@ describe('ShellIntegrationParser', () => { 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 `;`. + const { markers } = parser.parse(osc(`P;Cwd=C:\\\\Users\\\\x3b;${NONCE}`)) + + expect(markers).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`) @@ -104,6 +112,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() diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index da7349bbb27..575821eb08d 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,6 +55,7 @@ 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 } @@ -55,10 +67,14 @@ export interface ParseResult { markers: 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)) ) } @@ -131,6 +147,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': { @@ -246,23 +264,38 @@ function writeBashFile(dir: string, nonce: string): string { 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='' +# 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_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" } @@ -270,7 +303,9 @@ __sim_precmd() { local st=$? # 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 +314,115 @@ __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 + return $st +} + +# Set before the trap, so assigning it is not reported as a command. +PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $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 set a failing one, else 1 (a failing cmdlet has no code). + */ +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 + [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 + $exitCode = if ($succeeded) { 0 } elseif ($nativeExitCode -is [int] -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 +438,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 +462,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..ea0c3b87428 --- /dev/null +++ b/apps/desktop/src/main/terminal/windows-shells.test.ts @@ -0,0 +1,227 @@ +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 +` + +/** 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 a failure', + async () => { + const result = await run(open(shell), 'call-cmdlet', shell.failingCmdlet ?? '') + + expect(result.status).toBe('completed') + expect(result.exitCode).not.toBe(0) + }, + 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 leaves the shell usable', + async () => { + const scratch = scratchDirectory() + const script = join(scratch, 'stubborn.ps1') + const backgroundPidFile = join(scratch, 'background-pid') + const pidFile = join(scratch, 'pid') + writeFileSync(script, STUBBORN_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), stubbornArgs(backgroundPidFile)) + ) + const backgroundPid = await readPid(backgroundPidFile) + try { + const running = run(terminal, 'call-stubborn', command) + const pid = await readPid(pidFile) + + 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(await run(terminal, 'call-after', shell.succeed(0))).toMatchObject({ + exitCode: 0, + }) + } finally { + if (isAlive(backgroundPid)) process.kill(backgroundPid) + } + }, + 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 5ed6241ccfb..5d73929956b 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(), @@ -112,6 +113,7 @@ describe('initUpdater state machine', () => { probeOriginFeed?: (feedUrl: string) => Promise beforeInstall?: () => Promise setRelaunchPending?: (pending: boolean) => void + platform?: NodeJS.Platform }) { const states: DesktopUpdateState[] = [] const handle = initUpdater({ @@ -124,7 +126,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, }) @@ -143,6 +145,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 }) }) @@ -620,6 +624,115 @@ 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() + }) + + it('installs silently and relaunches after the restart is confirmed', 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' }) + + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ + response: 1, + checkboxChecked: false, + }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + + 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' }) + + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ + response: 1, + checkboxChecked: false, + }) + 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', () => { @@ -782,6 +895,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 05e786dd541..14482603416 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 only after the user confirms a relaunch. */ setRelaunchPending?: (pending: boolean) => void @@ -301,12 +340,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 @@ -353,7 +395,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) { @@ -422,8 +466,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 @@ -456,7 +502,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) @@ -592,7 +643,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') { @@ -646,9 +697,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 ( @@ -662,37 +728,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 @@ -704,13 +749,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 @@ -721,7 +795,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 @@ -885,7 +959,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)) { @@ -894,7 +968,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 @@ -903,10 +978,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) { @@ -961,7 +1040,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..980f2f1774b --- /dev/null +++ b/apps/desktop/src/main/win32-native-files.test.ts @@ -0,0 +1,212 @@ +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. + */ + +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 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..96e3ef7bce4 --- /dev/null +++ b/apps/desktop/src/main/win32-native-files.ts @@ -0,0 +1,131 @@ +import type { Dir, Dirent } from 'node:fs' +import { type FileHandle, lstat, open, opendir, realpath } 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 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. + * A path that resolves to a network share is refused too. + */ +async function canonicalBeneath(rootReal: string, path: string): Promise { + const real = await realpath(path) + if (real.startsWith('\\\\') || !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 narrows, but cannot close, a race against a process that swaps a path + * component between a check and the open. 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 realpath(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..4002771ce11 --- /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. UNC shares are refused outright, even inside a granted + * share, until grants can be matched lexically. A drive letter the user + * mapped to a share passes this check, since it reaches only the server the + * user chose; the file backend refuses it once it resolves to the share. Each + * segment must also pass {@link isWin32SafeSegment}. + */ +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..22770023c54 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -191,6 +191,18 @@ export function handleWillPreventUnload( } } +/** + * Points the native caption (and Electron-drawn menus) at Sim's own theme + * choice on Windows. Only the user's *preference* may drive this, never the + * resolved theme: `themeSource` also pins `prefers-color-scheme`, so 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 +222,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 +264,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 +282,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 +292,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 +442,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 811b07561f4..ac421deced7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -35,6 +35,7 @@ import type { DesktopNotificationPayload, DesktopOAuthConnectResult, DesktopOAuthConnectScope, + DesktopPlatform, DesktopPreferenceKey, DesktopPreferences, DesktopServerChangeResult, @@ -59,6 +60,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() @@ -115,13 +117,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'), @@ -416,11 +426,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 => @@ -428,6 +434,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'), @@ -455,12 +469,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..bf891b1355f 100644 --- a/apps/desktop/src/preload/shell-theme.ts +++ b/apps/desktop/src/preload/shell-theme.ts @@ -14,21 +14,36 @@ export function exposeShellTheme(): void { contextBridge.exposeInMainWorld('simShellTheme', api) } -/** Reports the actual app theme, including changes after hydration or OS theme changes. */ +/** The app's explicit theme choice (the next-themes `sim-theme` key), or `system`. */ +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 45141ccba49..2ab5f8a6012 100644 --- a/apps/desktop/src/renderer/offline/index.tsx +++ b/apps/desktop/src/renderer/offline/index.tsx @@ -112,7 +112,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 c0f180897d0..1510a9e9e77 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(), } @@ -323,6 +324,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/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx index 393f553975f..2ad245c4193 100644 --- a/apps/docs/content/docs/desktop/index.mdx +++ b/apps/docs/content/docs/desktop/index.mdx @@ -1,13 +1,13 @@ --- title: Sim Desktop -description: Install the macOS app and configure browser, terminal, local file, and available Mac app controls. +description: Install the macOS or Windows app and configure browser, terminal, local file, and available Mac app controls. --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' import { Image } from '@/components/ui/image' -Sim Desktop opens your Sim workspace in a macOS app. It adds a built-in browser, terminal sessions, local file tools, and native notifications. Available builds and deployments can also provide native Mac app control. Your account, workspaces, and workflows are shared with the web app. +Sim Desktop opens your Sim workspace in a macOS or Windows app. It adds a built-in browser, terminal sessions, local file tools, and native notifications. Available builds and deployments can also provide native Mac app control. Your account, workspaces, and workflows are shared with the web app. -universal.dmg` from [GitHub releases](https://github.com/simstudioai/sim/releases). +Use the web app on other platforms, including Windows on Arm. + +The download links redirect to the latest available desktop installer for that deployment. Results can be cached for five minutes. To install a specific version, download `Sim--universal.dmg` or `Sim--x64.exe` from [GitHub releases](https://github.com/simstudioai/sim/releases). You need outbound access to your Sim deployment and GitHub's release downloads. For a self-hosted deployment: - Use your own download endpoint, such as `https://sim.example.com/api/desktop/update/download`. - Allow the Sim server to reach both `api.github.com` and `github.com` for release lookup and manifests. Blocking either can make the download endpoint return `502`. -- Use a certificate trusted by macOS. The app rejects certificate errors without an override; install a private CA in the system trust store if required. +- Use a certificate trusted by the operating system. The app rejects certificate errors without an override; install a private CA in the system trust store if required. See [Desktop app on your deployment](/platform/self-hosting/desktop) for server URLs, update channels, and network troubleshooting. @@ -41,10 +46,12 @@ See [Desktop app on your deployment](/platform/self-hosting/desktop) for server ### Install in Applications -Open the disk image and drag Sim to **Applications**. Launch it from there. +On macOS, open the disk image and drag Sim to **Applications**. Launch it from there. + +On Windows, run the installer instead. It installs for your user account without an administrator prompt and opens Sim when it finishes. A new installer can show a Microsoft Defender SmartScreen warning until it builds download reputation; check that the publisher is Sim before choosing **More info → Run anyway**. - Automatic self-update requires a Developer ID build installed in Applications. Builds elsewhere use the manual installer update path. + On macOS, automatic self-update requires a Developer ID build installed in Applications. Builds elsewhere use the manual installer update path. @@ -68,10 +75,14 @@ The app has its own session. Signing out of the app does not sign you out of Sim ## Browser, terminal, and local files + + On Windows, native app control and importing from other browsers are not available yet. The built-in browser, Terminal, local file tools, notifications, and updates work as on macOS, except that tmux is not available in Terminal. + + | Capability | Behavior | | --- | --- | | **Browser** | Open websites in tabs with their own sessions and saved passwords. Chat can use the sites you sign into here. | -| **Terminal** | Open local shell sessions beside Chat. Chat can run commands in those sessions; tmux and shell integration are supported. | +| **Terminal** | Open local shell sessions beside Chat. Chat can run commands in those sessions; tmux (macOS) and shell integration are supported. On Windows, Terminal opens PowerShell 7 when it is installed, otherwise Windows PowerShell; set the `SHELL` environment variable to the path of Git Bash's `bash.exe` to use Git Bash instead. | | **Local file tools** | Read and search folders you approve, import files, and use authorized files in browser uploads. | | **Notifications** | Receive a notification when Chat finishes responding. Clicking it opens that chat. Errors and turns with queued follow-up messages do not produce completion notifications. | | **Control Center** | Open recent chats from Sim's menu-bar icon. | @@ -107,7 +118,7 @@ Use **Revoke** under **Approved apps** to remove a saved permission, or switch C ### Terminal access -Terminal commands run with your macOS account's permissions. They can modify files outside selected-folder grants; those grants do not confine the shell. Disable **Settings → Account → Terminal → Let Chat run commands** to remove this capability. +Terminal commands run with your macOS or Windows account's permissions. They can modify files outside selected-folder grants; those grants do not confine the shell. Disable **Settings → Account → Terminal → Let Chat run commands** to remove this capability. Command confirmation depends on Chat's tool-permission settings and whether the run is interactive. With approvals enabled, command runs can wait for approval unless the tool is auto-allowed. Noninteractive runs do not wait for a per-command decision. @@ -154,7 +165,7 @@ This section also shows the installed version and a downloaded version waiting f Use **Let Chat run commands** to enable or disable Terminal. **Theme** and **Default zoom** control its appearance. -The **Sim** menu also contains **Settings…**, **Server…**, **Check for Updates…**, and **Sign Out**. +The **Sim** menu also contains **Settings…**, **Server…**, **Check for Updates…**, and **Sign Out**. On Windows, **Settings…**, **Server…** and **Sign Out** are in the **File** menu and **Check for Updates…** is in **Help**; press Alt to show the menu bar. ## Keyboard shortcuts @@ -192,8 +203,8 @@ The app checks the deployment it is connected to for updates. It does not force | Installation | Update behavior | | --- | --- | -| Developer ID build in **Applications** | The app can replace itself. With **Automatically download updates** on, it downloads in the background and offers to restart. Choose **Later** to apply the downloaded update when you next quit. With the setting off, **Sim → Check for Updates…** offers a version and waits for **Download**. | -| Build outside **Applications**, or without Developer ID signing | The app offers an installer for you to download and install manually. | +| Windows installer, or a Developer ID build in **Applications** on macOS | The app can replace itself. With **Automatically download updates** on, it downloads in the background and offers to restart. Choose **Later** to apply the downloaded update when you next quit. With the setting off, **Sim → Check for Updates…** offers a version and waits for **Download**. | +| macOS build outside **Applications**, or without Developer ID signing | The app offers an installer for you to download and install manually. | If the deployment's update feed is unavailable, an eligible stable build falls back to Sim's public GitHub releases. Prerelease builds skip the check instead. See [Desktop app on your deployment](/platform/self-hosting/desktop#what-an-installed-app-does-when-the-feed-fails) for feed failures and release selection. diff --git a/apps/docs/content/docs/platform/self-hosting/desktop.mdx b/apps/docs/content/docs/platform/self-hosting/desktop.mdx index a05697e60b3..0c0bdde9215 100644 --- a/apps/docs/content/docs/platform/self-hosting/desktop.mdx +++ b/apps/docs/content/docs/platform/self-hosting/desktop.mdx @@ -1,19 +1,19 @@ --- title: Desktop app on your deployment -description: Connect the macOS desktop app to a Sim deployment you operate +description: Connect the macOS or Windows desktop app to a Sim deployment you operate --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -Install the official Sim desktop app and connect it to your deployment. You do not need a custom build to change servers. The app is currently macOS-only; use the web app on other platforms. See [Sim Desktop](/desktop) for general installation and use. +Install the official Sim desktop app and connect it to your deployment. You do not need a custom build to change servers. The app runs on macOS and on Windows (x64, Windows 10 version 1809 or later, or Windows 11); use the web app on other platforms. See [Sim Desktop](/desktop) for general installation and use. ## Server prerequisites [#requirements-for-the-server-url] - Use the origin your server serves, including scheme, hostname, and port. Paths are ignored. - HTTPS is required except for `localhost`, `127.0.0.1`, and `::1`, which allow HTTP for local testing. - Do not include credentials in the URL. -- Use a certificate trusted by macOS. +- Use a certificate trusted by the operating system. - Allow your Sim server to reach `api.github.com` and `github.com` for official installer and update lookup. @@ -39,21 +39,21 @@ The command reads Compose/Helm configuration, resolves the installer, and prints bunx sim-setup desktop --url https://sim.example.com --no-open ``` -`--url` skips discovery. Without the CLI, open `https:///api/desktop/update/download` in a browser. +`--url` skips discovery. The command resolves the installer for the machine it runs on. Without the CLI, open `https:///api/desktop/update/download` in a browser, adding `?platform=windows` for the Windows installer. ### Install the app -Open the `.dmg` and drag Sim to **Applications**. Official installers are signed and notarized by Sim. +On macOS, open the `.dmg` and drag Sim to **Applications**. On Windows, run the `.exe`; it installs for the current user. Official installers are signed by Sim, and macOS installers are also notarized. ### Connect to your server -Launch Sim, choose **Sim → Server…**, enter your deployment origin, and select **Connect**. The app relaunches against that server and saves the setting across updates. +Launch Sim, choose **Sim → Server…** (on Windows, **File → Server…**; press Alt to show the menu bar), enter your deployment origin, and select **Connect**. The app relaunches against that server and saves the setting across updates. @@ -75,18 +75,19 @@ Window size, zoom, theme, notification preferences, tray, and launch-at-login se The **Can't connect** page reports a DNS, timeout, or TLS failure. Select **Change server** to open the picker with the current value prefilled. No filesystem change is needed to correct a typo. -A private or self-signed certificate must be trusted by macOS; otherwise the app shows **Connection isn't secure**. A correct URL does not bypass certificate validation. +A private or self-signed certificate must be trusted by the operating system; otherwise the app shows **Connection isn't secure**. A correct URL does not bypass certificate validation. ## Installer and update endpoints [#your-deployment-already-serves-the-installer] -Your deployment exposes two public endpoints: +Your deployment exposes these public endpoints: | Endpoint | Response | | --- | --- | -| `/api/desktop/update/download` | `302` to the newest official installer for the deployment channel. | -| `/api/desktop/update/latest-mac.yml` | `200` with a `text/yaml` update manifest. | +| `/api/desktop/update/download` | `302` to the newest official macOS installer for the deployment channel. Add `?platform=windows` for the Windows installer. | +| `/api/desktop/update/latest-mac.yml` | `200` with the macOS `text/yaml` update manifest. | +| `/api/desktop/update/latest.yml` | `200` with the Windows `text/yaml` update manifest. | -Both resolve official GitHub release assets and cache lookups for five minutes. They return `404` when the channel has no release and `502` when GitHub lookup fails or candidates lack valid desktop artifacts. +All resolve official GitHub release assets and cache lookups for five minutes. They return `404` when the channel has no release for the platform and `502` when GitHub lookup fails or candidates lack valid desktop artifacts. Windows builds are published separately from macOS builds, so the Windows endpoints can serve an older release than the macOS ones. The server serves release selection and the manifest; clients download installers from GitHub. These endpoints cannot distribute your custom artifacts. Set `GITHUB_TOKEN` on the Sim server if its shared egress IP reaches GitHub's unauthenticated 60-requests/hour limit; authenticated user-token requests typically allow 5,000/hour. See [GitHub rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api). @@ -112,7 +113,7 @@ A feed `404` with the no-release header means no update is available. For `502`, - A `dev` or `staging` build skips the check because the stable fallback cannot update its bundle identity. - Other builds receive no update offer until the deployment feed answers again. -Self-update requires a Developer ID build installed in **Applications**. Other packaged builds can offer manual downloads when the feed works. Release selection therefore follows your deployment only while its feed answers; an eligible stable app can use the fallback during an outage. +On macOS, self-update requires a Developer ID build installed in **Applications**; official Windows installs always self-update. Other packaged builds can offer manual downloads when the feed works. Release selection therefore follows your deployment only while its feed answers; an eligible stable app can use the fallback during an outage. ## CLI discovery and probe failures @@ -131,6 +132,8 @@ SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.example.com bun run package:mac The default origin applies to fresh installs; the server picker remains available. The macOS target builds a universal binary. Output files are `apps/desktop/release/Sim--universal.dmg` and `.zip`. Pass `-c.appId=com.example.sim` to choose a bundle identifier. +On Windows, `bun run package:win` builds `apps/desktop/release/Sim--x64.exe`. Pass `-c.extraMetadata.name=` with a unique slug as well as `-c.appId`, because one-click Windows installs use the package name for their install directory. Sign it with your own Authenticode certificate. + The baked default determines the app's channel identity, userData directory, and single-instance lock. Loopback, `dev.sim.ai`, and `staging.sim.ai` defaults produce separate Local, Dev, and Staging app identities; other origins use the production Sim identity. A Local build can still be pointed at another HTTPS server, but changing servers does not turn it into a production build or change its update eligibility. `package:share` instead writes `apps/desktop/release//--.` and disables trusted timestamps. Use `package:mac` for notarized distribution. 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 ed231c04534..ef832cc608c 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]/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..1657597b667 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/desktop/index.test.ts b/apps/sim/lib/desktop/index.test.ts index 1a7436c7e83..330f8350215 100644 --- a/apps/sim/lib/desktop/index.test.ts +++ b/apps/sim/lib/desktop/index.test.ts @@ -201,6 +201,7 @@ describe('desktop surface availability', () => { running: oversizedValue, interactive: false, active: true, + shell: oversizedValue, }, ], })), @@ -219,6 +220,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 44ce2c97b81..f4d0bb25edb 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -22,7 +22,7 @@ * 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 { requestJson } from '@/lib/api/client/request' import { @@ -31,6 +31,7 @@ import { } from '@/lib/api/contracts/computer-use' 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' @@ -139,6 +140,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 @@ -155,6 +165,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 { @@ -165,6 +177,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. */ @@ -225,6 +238,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(() => []) @@ -236,6 +252,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) @@ -250,6 +267,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 c82da7f3b93..8271e39528a 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -894,7 +894,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' }, ], @@ -903,7 +904,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 33b758e3afb..81d58e61524 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' @@ -98,12 +99,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[] } @@ -555,6 +559,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 bb68ef96984..60cf45022a1 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 2b252886dde..fe4ec78b475 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 2650e445146..dbea0e2ac1c 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:*", @@ -84,6 +82,12 @@ "typescript": "^7.0.2", "vitest": "^5.0.4", }, + "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 248c84ce8c6..8ae278e609b 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -585,7 +585,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 @@ -593,10 +593,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 @@ -938,7 +944,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 @@ -1171,10 +1177,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 @@ -1217,7 +1231,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. */ @@ -1228,7 +1242,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 { @@ -1242,3 +1256,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/README.md b/packages/sim-setup/README.md index 944115c75cc..964e15e7b57 100644 --- a/packages/sim-setup/README.md +++ b/packages/sim-setup/README.md @@ -155,8 +155,8 @@ running and healthy?” ### Install the desktop app -`desktop` resolves the macOS installer from your own deployment and prints the -server URL to enter in the app: +`desktop` resolves the installer for your machine (macOS, or Windows x64) from +your own deployment and prints the server URL to enter in the app: ```bash npx sim-setup desktop @@ -167,7 +167,7 @@ npx sim-setup desktop --no-open The desktop app is not tied to sim.ai — it bakes in only a default server, and every Sim deployment already serves `/api/desktop/update/download` and the update feed installed apps poll. Install the signed build, then point it at your -deployment with **Sim → Server…**. Nothing has to be built or signed by you. +deployment with **Server…** (in the Sim menu on macOS, the File menu on Windows). Nothing has to be built or signed by you. ### Add or change capabilities diff --git a/packages/sim-setup/src/desktop.ts b/packages/sim-setup/src/desktop.ts index 60132f633ad..2477faa0041 100644 --- a/packages/sim-setup/src/desktop.ts +++ b/packages/sim-setup/src/desktop.ts @@ -10,12 +10,29 @@ 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: { + downloadPath: '/api/desktop/update/download', + feedPath: '/api/desktop/update/latest-mac.yml', + serverCommand: 'Sim → Server…', + serverCommandWhere: 'in the menu bar', + }, + windows: { + 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 + +/** The desktop build for the machine running the CLI; macOS unless it is Windows. */ +function desktopPlatformForHost(): (typeof DESKTOP_PLATFORMS)[keyof typeof DESKTOP_PLATFORMS] { + return process.platform === 'win32' ? DESKTOP_PLATFORMS.windows : DESKTOP_PLATFORMS.mac +} const PROBE_TIMEOUT_MS = 15_000 @@ -219,7 +236,8 @@ 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)}`) @@ -227,7 +245,7 @@ export async function runDesktop(flags: DesktopFlags): Promise { spin.start('Resolving the desktop installer…') const [probe, feedOk] = await Promise.all([ probeDownload(downloadUrl), - httpHealth(`${appUrl}${FEED_PATH}`, PROBE_TIMEOUT_MS), + httpHealth(`${appUrl}${platform.feedPath}`, PROBE_TIMEOUT_MS), ]) const { headline, hints } = describeProbe(probe, appUrl) spin.stop(headline) @@ -242,7 +260,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 +269,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..244091bade5 100644 --- a/packages/terminal-protocol/src/index.test.ts +++ b/packages/terminal-protocol/src/index.test.ts @@ -26,4 +26,22 @@ 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('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..cbc85f0505d 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 } @@ -353,12 +398,14 @@ 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 syntax = (shell && SHELL_SYNTAX.get(shell)) || POSIX_SYNTAX + const segment = lastCommandSegment(line, syntax) || line const program = segment .split(/\s+/) .filter(Boolean) @@ -371,15 +418,29 @@ export function describeRunningCommand(running: string): string { const label = (program - ? (program - .replace(/^['"]|['"]$/g, '') - .split('/') - .filter(Boolean) - .pop() ?? '') + ? lastPathSegment(program.replace(/^['"]|['"]$/g, ''), 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 1afbb402217..c7fe29230e8 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',