diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 16babab1c9a..58c96a86e5b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -14,10 +14,6 @@ inputs: description: Node version to install. Empty keeps the runner's preinstalled Node, for jobs that only ever ran Bun. required: false default: '24' - registry-url: - description: npm registry to write an .npmrc for, so `npm publish` reads NODE_AUTH_TOKEN. Empty writes none. - required: false - default: '' # Sticky disk keys are scoped by event name, and fork PRs get their own namespace on # top: untrusted fork runs must never share a cache with push runs (whose caches @@ -43,7 +39,6 @@ runs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: ${{ inputs.node-version }} - registry-url: ${{ inputs.registry-url }} - name: Mount Bun cache if: inputs.provider == '' || inputs.provider == 'blacksmith' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index a6696c27926..5eef3cc7e1a 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -15,10 +15,13 @@ concurrency: cancel-in-progress: false jobs: - publish: - name: publish + build: + name: build runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 + outputs: + version: ${{ steps.package_version.outputs.version }} + exists: ${{ steps.version_check.outputs.exists }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -30,7 +33,6 @@ jobs: with: provider: ${{ vars.CI_PROVIDER }} node-version: '22' - registry-url: https://registry.npmjs.org/ - name: Build package working-directory: packages/cli @@ -39,7 +41,7 @@ jobs: - name: Get package version id: package_version working-directory: packages/cli - run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - name: Check if version already exists id: version_check @@ -47,20 +49,59 @@ jobs: VERSION: ${{ steps.package_version.outputs.version }} run: | if npm view "simstudio@$VERSION" version &> /dev/null; then - echo "exists=true" >> $GITHUB_OUTPUT + echo "exists=true" >> "$GITHUB_OUTPUT" else - echo "exists=false" >> $GITHUB_OUTPUT + echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Publish to npm + # Bun packs because it rewrites `workspace:` ranges to real versions, + # which `npm pack` would publish verbatim. + - name: Pack package if: steps.version_check.outputs.exists == 'false' working-directory: packages/cli - run: npm publish --access=public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/package.tgz" --quiet + + - name: Upload package + if: steps.version_check.outputs.exists == 'false' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package + path: ${{ runner.temp }}/package.tgz + if-no-files-found: error + retention-days: 1 - name: Log skipped publish if: steps.version_check.outputs.exists == 'true' env: VERSION: ${{ steps.package_version.outputs.version }} - run: echo "Skipped publishing because version $VERSION already exists on npm" \ No newline at end of file + run: echo "Skipped publishing because version $VERSION already exists on npm" + + # npm trusted publishing trades this job's OIDC token for a short-lived npm + # credential, so no npm token is stored anywhere. npm accepts the token only + # from GitHub-hosted runners, and only this job can mint one: it runs no + # repository code, it publishes the tarball the build job packed. npm runs no + # lifecycle scripts for a tarball, so nothing is rebuilt here, and provenance + # is attached automatically. + publish: + name: publish + needs: build + if: needs.build.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + # Node 24 bundles npm 11.5.1+, which trusted publishing requires. + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '24' + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: ${{ runner.temp }} + + - name: Publish to npm + run: npm publish "$RUNNER_TEMP/package.tgz" --access public diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index 591748b1606..2d87aa6bd92 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -19,10 +19,13 @@ concurrency: cancel-in-progress: false jobs: - publish: - name: publish + build: + name: build runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -37,11 +40,6 @@ jobs: provider: ${{ vars.CI_PROVIDER }} node-version: '22' - - name: Verify npm authentication - env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - run: bun pm whoami - - name: Auto-bump package version run: bun run bump:npm-packages sim-cli @@ -54,9 +52,10 @@ jobs: run: bun run type-check # The usage-reporting destination is baked into the bundle at build time - # (docs/cli/usage-data), so only the two steps that build it get the - # token. Every other step, the tests above in particular, runs without - # it: a test that runs a real command must never report to production. + # (docs/cli/usage-data), so only this step gets the token, and the + # tarball packed from its output is what ships. Every other step, the + # tests above in particular, runs without it: a test that runs a real + # command must never report to production. - name: Build package working-directory: packages/sim-cli env: @@ -107,23 +106,28 @@ jobs: echo "tag=$TAG" } >> "$GITHUB_OUTPUT" + # Bun packs because it rewrites `workspace:` ranges to real versions, + # which `npm pack` would publish verbatim. The smoke test below and the + # publish job both use this one tarball, so what was tested is what ships. + - name: Pack package + working-directory: packages/sim-cli + run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/package.tgz" --quiet + - name: Setup Node 20 for the bundle smoke test uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: '20' - name: Smoke-test packed Node bundle - working-directory: packages/sim-cli + env: + VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail SMOKE_DIR="$(mktemp -d "$RUNNER_TEMP/sim-cli-smoke.XXXXXX")" - PACKAGE_PATH="$SMOKE_DIR/sim-cli.tgz" - bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet - tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" - "$SMOKE_DIR/package/dist/index.js" --version + tar -xzf "$RUNNER_TEMP/package.tgz" -C "$SMOKE_DIR" + test "$("$SMOKE_DIR/package/dist/index.js" --version)" = "$VERSION" - name: Verify version is unpublished - working-directory: packages/sim-cli env: VERSION: ${{ steps.release.outputs.version }} run: | @@ -132,19 +136,47 @@ jobs: exit 1 fi - # `bun publish` runs `prepublishOnly`, which rebuilds `dist`, and that - # rebuild is what ships, so it needs the token as well. + - name: Upload package + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package + path: ${{ runner.temp }}/package.tgz + if-no-files-found: error + retention-days: 1 + + # npm trusted publishing trades this job's OIDC token for a short-lived npm + # credential, so no npm token is stored anywhere. npm accepts the token only + # from GitHub-hosted runners, and only this job can mint one: it runs no + # repository code, it publishes the tarball the build job tested. npm runs no + # lifecycle scripts for a tarball, so nothing is rebuilt here, and provenance + # is attached automatically. + publish: + name: publish + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + # Node 24 bundles npm 11.5.1+, which trusted publishing requires. + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '24' + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: ${{ runner.temp }} + - name: Publish to npm - working-directory: packages/sim-cli env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TAG: ${{ steps.release.outputs.tag }} - SIM_CLI_TELEMETRY_KEY: ${{ vars.SIM_CLI_TELEMETRY_KEY }} - SIM_CLI_TELEMETRY_HOST: ${{ vars.SIM_CLI_TELEMETRY_HOST }} - run: bun publish --access public --tag "$NPM_TAG" --no-save + NPM_TAG: ${{ needs.build.outputs.tag }} + run: npm publish "$RUNNER_TEMP/package.tgz" --access public --tag "$NPM_TAG" - name: Summarize release env: - VERSION: ${{ steps.release.outputs.version }} - NPM_TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ needs.build.outputs.version }} + NPM_TAG: ${{ needs.build.outputs.tag }} run: echo "Published sim@$VERSION with the '$NPM_TAG' tag." diff --git a/.github/workflows/publish-sim-setup.yml b/.github/workflows/publish-sim-setup.yml index 2bcedc4b5db..3356f40ed9f 100644 --- a/.github/workflows/publish-sim-setup.yml +++ b/.github/workflows/publish-sim-setup.yml @@ -10,6 +10,8 @@ on: - 'packages/utils/**' - 'docker-compose.prod.yml' - 'bun.lock' + # A change to how the package is built or published has to republish it. + - '.github/workflows/publish-sim-setup.yml' permissions: contents: read @@ -19,10 +21,13 @@ concurrency: cancel-in-progress: false jobs: - publish: - name: publish + build: + name: build runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -37,11 +42,6 @@ jobs: provider: ${{ vars.CI_PROVIDER }} node-version: '22' - - name: Verify npm authentication - env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - run: bun pm whoami - - name: Auto-bump package version run: bun run bump:npm-packages sim-setup @@ -104,21 +104,27 @@ jobs: echo "tag=$TAG" } >> "$GITHUB_OUTPUT" + # Bun packs because it rewrites `workspace:` ranges to real versions, + # which `npm pack` would publish verbatim. The smoke test below and the + # publish job both use this one tarball, so what was tested is what ships. + - name: Pack package + working-directory: packages/sim-setup + run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/package.tgz" --quiet + - name: Setup Node 20 for the bundle smoke test uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: '20' - name: Smoke-test packed Node bundle - working-directory: packages/sim-setup + env: + VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail SMOKE_DIR="$(mktemp -d "$RUNNER_TEMP/sim-setup-smoke.XXXXXX")" - PACKAGE_PATH="$SMOKE_DIR/sim-setup.tgz" - bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet - tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" + tar -xzf "$RUNNER_TEMP/package.tgz" -C "$SMOKE_DIR" - if tar -tzf "$PACKAGE_PATH" | grep -Eq '^package/(apps|docker|helm|packages|scripts)/'; then + if tar -tzf "$RUNNER_TEMP/package.tgz" | grep -Eq '^package/(apps|docker|helm|packages|scripts)/'; then echo 'Packed setup package contains repository source directories.' >&2 exit 1 fi @@ -130,7 +136,7 @@ jobs: fi cd "$SMOKE_DIR" - node package/dist/index.js --version + test "$(node package/dist/index.js --version)" = "$VERSION" node package/dist/index.js --help for command in config add doctor start stop restart update status logs down reset; do node package/dist/index.js "$command" --help > /dev/null @@ -146,7 +152,6 @@ jobs: fi - name: Verify version is unpublished - working-directory: packages/sim-setup env: VERSION: ${{ steps.release.outputs.version }} run: | @@ -155,15 +160,47 @@ jobs: exit 1 fi + - name: Upload package + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package + path: ${{ runner.temp }}/package.tgz + if-no-files-found: error + retention-days: 1 + + # npm trusted publishing trades this job's OIDC token for a short-lived npm + # credential, so no npm token is stored anywhere. npm accepts the token only + # from GitHub-hosted runners, and only this job can mint one: it runs no + # repository code, it publishes the tarball the build job tested. npm runs no + # lifecycle scripts for a tarball, so nothing is rebuilt here, and provenance + # is attached automatically. + publish: + name: publish + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + # Node 24 bundles npm 11.5.1+, which trusted publishing requires. + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '24' + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: ${{ runner.temp }} + - name: Publish to npm - working-directory: packages/sim-setup env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TAG: ${{ steps.release.outputs.tag }} - run: bun publish --access public --tag "$NPM_TAG" --no-save + NPM_TAG: ${{ needs.build.outputs.tag }} + run: npm publish "$RUNNER_TEMP/package.tgz" --access public --tag "$NPM_TAG" - name: Summarize release env: - VERSION: ${{ steps.release.outputs.version }} - NPM_TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ needs.build.outputs.version }} + NPM_TAG: ${{ needs.build.outputs.tag }} run: echo "Published sim-setup@$VERSION with the '$NPM_TAG' tag." diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index 1c5e7e35e40..8a3c9c15b5e 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -7,7 +7,7 @@ on: - 'packages/ts-sdk/**' permissions: - contents: write + contents: read # A newer push waits for the running publish rather than racing its version check. concurrency: @@ -15,10 +15,13 @@ concurrency: cancel-in-progress: false jobs: - publish: - name: publish + build: + name: build runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 + outputs: + version: ${{ steps.package_version.outputs.version }} + exists: ${{ steps.version_check.outputs.exists }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -30,7 +33,6 @@ jobs: with: provider: ${{ vars.CI_PROVIDER }} node-version: '22' - registry-url: https://registry.npmjs.org/ - name: Run tests working-directory: packages/ts-sdk @@ -43,7 +45,7 @@ jobs: - name: Get package version id: package_version working-directory: packages/ts-sdk - run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - name: Check if version already exists id: version_check @@ -51,17 +53,26 @@ jobs: VERSION: ${{ steps.package_version.outputs.version }} run: | if npm view "simstudio-ts-sdk@$VERSION" version &> /dev/null; then - echo "exists=true" >> $GITHUB_OUTPUT + echo "exists=true" >> "$GITHUB_OUTPUT" else - echo "exists=false" >> $GITHUB_OUTPUT + echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Publish to npm + # Bun packs because it rewrites `workspace:` ranges to real versions, + # which `npm pack` would publish verbatim. + - name: Pack package if: steps.version_check.outputs.exists == 'false' working-directory: packages/ts-sdk - run: npm publish --access=public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/package.tgz" --quiet + + - name: Upload package + if: steps.version_check.outputs.exists == 'false' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package + path: ${{ runner.temp }}/package.tgz + if-no-files-found: error + retention-days: 1 - name: Log skipped publish if: steps.version_check.outputs.exists == 'true' @@ -69,25 +80,62 @@ jobs: VERSION: ${{ steps.package_version.outputs.version }} run: echo "Skipped publishing because version $VERSION already exists on npm" + # npm trusted publishing trades this job's OIDC token for a short-lived npm + # credential, so no npm token is stored anywhere. npm accepts the token only + # from GitHub-hosted runners, and only this job can mint one: it runs no + # repository code, it publishes the tarball the build job tested. npm runs no + # lifecycle scripts for a tarball, so nothing is rebuilt here, and provenance + # is attached automatically. + publish: + name: publish + needs: build + if: needs.build.outputs.exists == 'false' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + # Node 24 bundles npm 11.5.1+, which trusted publishing requires. + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '24' + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: ${{ runner.temp }} + + - name: Publish to npm + run: npm publish "$RUNNER_TEMP/package.tgz" --access public + + release: + name: release + needs: [build, publish] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: - name: Create GitHub Release - if: steps.version_check.outputs.exists == 'false' uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tag_name: typescript-sdk-v${{ steps.package_version.outputs.version }} - name: TypeScript SDK v${{ steps.package_version.outputs.version }} + tag_name: typescript-sdk-v${{ needs.build.outputs.version }} + name: TypeScript SDK v${{ needs.build.outputs.version }} body: | - ## TypeScript SDK v${{ steps.package_version.outputs.version }} - - Published simstudio-ts-sdk@${{ steps.package_version.outputs.version }} to npm. - + ## TypeScript SDK v${{ needs.build.outputs.version }} + + Published simstudio-ts-sdk@${{ needs.build.outputs.version }} to npm. + ### Installation ```bash - npm install simstudio-ts-sdk@${{ steps.package_version.outputs.version }} + npm install simstudio-ts-sdk@${{ needs.build.outputs.version }} ``` - + ### Documentation See the [README](https://github.com/simstudioai/sim/tree/main/packages/ts-sdk) or the [docs](https://docs.sim.ai/sdks/typescript) for more information. draft: false - prerelease: false \ No newline at end of file + prerelease: false diff --git a/apps/desktop/e2e/source-connect.spec.ts b/apps/desktop/e2e/source-connect.spec.ts index 2afbc38d1b5..6f5925bb3c9 100644 --- a/apps/desktop/e2e/source-connect.spec.ts +++ b/apps/desktop/e2e/source-connect.spec.ts @@ -117,6 +117,7 @@ test('source authorization returns to its desktop screen and refreshes live', as groupId: 'fixture-group', optionId: 'fixture-option', status: 'active', + sites: [], }, ] : [], diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index a52cc14977e..5b303ac360b 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -72,6 +72,9 @@ mac: NSScreenCaptureUsageDescription: Mothership captures approved app windows to understand and verify computer-use actions. NSMicrophoneUsageDescription: Sim uses your microphone for voice input in Chat and for meetings you join in the built-in browser. NSCameraUsageDescription: Sim uses your camera for meetings you join in the built-in browser, such as Google Meet. + # macOS 15 blocks connections to private addresses (self-hosted servers on a + # LAN or VPN) until the user allows Sim; this string explains that prompt. + NSLocalNetworkUsageDescription: Sim connects to your self-hosted Sim server and to local addresses you open in the built-in browser. entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist notarize: true diff --git a/apps/desktop/src/main/load-health.test.ts b/apps/desktop/src/main/load-health.test.ts index aab1da96f78..aac0ee58109 100644 --- a/apps/desktop/src/main/load-health.test.ts +++ b/apps/desktop/src/main/load-health.test.ts @@ -3,9 +3,20 @@ import { attachLoadHealth, classifyLoadError } from '@/main/load-health' import { BrowserWindow as MockBrowserWindow } from '@/test/electron-mock' describe('classifyLoadError', () => { + const SELF_HOSTED_URL = 'http://sim.corp.internal:3000/workspace' + it('does NOT ignore ERR_FAILED (-2) or ERR_IO_PENDING (-1)', () => { - expect(classifyLoadError(-2)).toBe('unreachable') - expect(classifyLoadError(-1)).toBe('unreachable') + expect(classifyLoadError(-2, SELF_HOSTED_URL)).toBe('unreachable') + expect(classifyLoadError(-1, SELF_HOSTED_URL)).toBe('unreachable') + }) + + it('reports ERR_ADDRESS_UNREACHABLE as a local-network block only under Local Network privacy', () => { + expect(classifyLoadError(-109, SELF_HOSTED_URL, true)).toBe('local_network') + expect(classifyLoadError(-109, SELF_HOSTED_URL, false)).toBe('unreachable') + }) + + it('never blames Local Network privacy for Sim Cloud, which is public', () => { + expect(classifyLoadError(-109, 'https://www.sim.ai/workspace', true)).toBe('unreachable') }) }) diff --git a/apps/desktop/src/main/load-health.ts b/apps/desktop/src/main/load-health.ts index de71f85ecb9..29019c2af2d 100644 --- a/apps/desktop/src/main/load-health.ts +++ b/apps/desktop/src/main/load-health.ts @@ -1,19 +1,41 @@ +import { release } from 'node:os' import { createLogger } from '@sim/logger' import type { BrowserWindow } from 'electron' +import { isSimCloudOrigin } from '@/main/config' import { type EventRecorder, scrubUrl } from '@/main/observability' const logger = createLogger('DesktopLoadHealth') const AUTO_RETRY_INTERVAL_MS = 5000 const LOAD_WATCHDOG_MS = 30_000 +/** Darwin 24 is macOS 15, the first release that gates apps behind Local Network privacy. */ +const FIRST_LOCAL_NETWORK_PRIVACY_DARWIN = 24 -export type LoadErrorKind = 'offline' | 'dns' | 'tls' | 'timeout' | 'unreachable' | 'ignored' +function hasLocalNetworkPrivacy(): boolean { + return ( + process.platform === 'darwin' && + Number.parseInt(release(), 10) >= FIRST_LOCAL_NETWORK_PRIVACY_DARWIN + ) +} + +export type LoadErrorKind = + | 'offline' + | 'dns' + | 'tls' + | 'timeout' + | 'local_network' + | 'unreachable' + | 'ignored' /** * Maps Chromium net error codes to recovery copy. -3 (ERR_ABORTED) is emitted * constantly by OAuth redirect chains and in-app aborts and must be ignored. */ -export function classifyLoadError(errorCode: number): LoadErrorKind { +export function classifyLoadError( + errorCode: number, + url: string, + localNetworkPrivacy: boolean = hasLocalNetworkPrivacy() +): LoadErrorKind { // Only ERR_ABORTED (-3) and success (0) are ignored. ERR_FAILED (-2) and // ERR_IO_PENDING (-1) are real failures that must surface the offline page. if (errorCode === 0 || errorCode === -3) { @@ -31,6 +53,13 @@ export function classifyLoadError(errorCode: number): LoadErrorKind { if (errorCode <= -200 && errorCode >= -213) { return 'tls' } + // macOS Local Network privacy fails the socket with EHOSTUNREACH, which + // Chromium reports as ERR_ADDRESS_UNREACHABLE (-109), so a self-hosted server + // on a private address looks like a routing failure until Sim is allowed. + // Sim Cloud is public, so that permission can never be what blocked it. + if (errorCode === -109 && localNetworkPrivacy && !isSimCloudOrigin(url)) { + return 'local_network' + } return 'unreachable' } @@ -125,7 +154,7 @@ export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): Load return } clearTimeout(watchdogTimer) - const kind = classifyLoadError(errorCode) + const kind = classifyLoadError(errorCode, validatedURL) if (kind === 'ignored') { return } diff --git a/apps/desktop/src/renderer/offline/index.tsx b/apps/desktop/src/renderer/offline/index.tsx index 45141ccba49..2b507f46e3b 100644 --- a/apps/desktop/src/renderer/offline/index.tsx +++ b/apps/desktop/src/renderer/offline/index.tsx @@ -30,6 +30,11 @@ const ERROR_COPY = { title: 'The server isn’t responding', message: 'The connection timed out. The server may be down or your network may be blocking it.', }, + local_network: { + title: 'Can’t reach the server', + message: + 'If your Sim server is on a local or private network, macOS may be blocking Sim. Turn on Sim in System Settings > Privacy & Security > Local Network, then try again.', + }, unreachable: { title: 'Can’t connect to Sim', message: 'Sim couldn’t reach the server. Check your internet connection, then try again.', diff --git a/apps/docs/components/docs/navigation-drawer.tsx b/apps/docs/components/docs/navigation-drawer.tsx index fa44acbe3b7..03aa7abcb58 100644 --- a/apps/docs/components/docs/navigation-drawer.tsx +++ b/apps/docs/components/docs/navigation-drawer.tsx @@ -54,7 +54,7 @@ export function DocsNavigationDrawer({ children }: DocsNavigationDrawerProps) { }} className={cn( docsScrollAreaClass, - '-mx-1 mt-5 min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 pb-6' + '-mx-1 mt-5 min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-1 pb-6' )} >