From bc1cbffc969503186576888af1e6ffa22758589b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 7 Nov 2026 12:24:52 -0800 Subject: [PATCH 1/2] fix(desktop): explain macOS Local Network blocks for private self-hosted servers --- apps/desktop/electron-builder.yml | 3 +++ apps/desktop/src/main/load-health.test.ts | 6 ++++++ apps/desktop/src/main/load-health.ts | 20 ++++++++++++++++++-- apps/desktop/src/renderer/offline/index.tsx | 5 +++++ 4 files changed, 32 insertions(+), 2 deletions(-) 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..e0993469842 100644 --- a/apps/desktop/src/main/load-health.test.ts +++ b/apps/desktop/src/main/load-health.test.ts @@ -7,6 +7,12 @@ describe('classifyLoadError', () => { expect(classifyLoadError(-2)).toBe('unreachable') expect(classifyLoadError(-1)).toBe('unreachable') }) + + it('reports ERR_ADDRESS_UNREACHABLE as a local-network block only on macOS', () => { + expect(classifyLoadError(-109, 'darwin')).toBe('local_network') + expect(classifyLoadError(-109, 'linux')).toBe('unreachable') + expect(classifyLoadError(-109, 'win32')).toBe('unreachable') + }) }) vi.mock('electron', () => import('@/test/electron-mock')) diff --git a/apps/desktop/src/main/load-health.ts b/apps/desktop/src/main/load-health.ts index de71f85ecb9..a3b96e70002 100644 --- a/apps/desktop/src/main/load-health.ts +++ b/apps/desktop/src/main/load-health.ts @@ -7,13 +7,23 @@ const logger = createLogger('DesktopLoadHealth') const AUTO_RETRY_INTERVAL_MS = 5000 const LOAD_WATCHDOG_MS = 30_000 -export type LoadErrorKind = 'offline' | 'dns' | 'tls' | 'timeout' | 'unreachable' | 'ignored' +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, + platform: NodeJS.Platform = process.platform +): 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 +41,12 @@ 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. + if (errorCode === -109 && platform === 'darwin') { + return 'local_network' + } return 'unreachable' } 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.', From e362e1df47505715687dcaaf6f5b1e696eafc70b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 7 Nov 2026 12:30:38 -0800 Subject: [PATCH 2/2] fix(desktop): only show Local Network guidance on macOS 15+ --- apps/desktop/src/main/load-health.test.ts | 7 +++---- apps/desktop/src/main/load-health.ts | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/load-health.test.ts b/apps/desktop/src/main/load-health.test.ts index e0993469842..e547daa4836 100644 --- a/apps/desktop/src/main/load-health.test.ts +++ b/apps/desktop/src/main/load-health.test.ts @@ -8,10 +8,9 @@ describe('classifyLoadError', () => { expect(classifyLoadError(-1)).toBe('unreachable') }) - it('reports ERR_ADDRESS_UNREACHABLE as a local-network block only on macOS', () => { - expect(classifyLoadError(-109, 'darwin')).toBe('local_network') - expect(classifyLoadError(-109, 'linux')).toBe('unreachable') - expect(classifyLoadError(-109, 'win32')).toBe('unreachable') + it('reports ERR_ADDRESS_UNREACHABLE as a local-network block only under Local Network privacy', () => { + expect(classifyLoadError(-109, true)).toBe('local_network') + expect(classifyLoadError(-109, false)).toBe('unreachable') }) }) diff --git a/apps/desktop/src/main/load-health.ts b/apps/desktop/src/main/load-health.ts index a3b96e70002..56c73d89f45 100644 --- a/apps/desktop/src/main/load-health.ts +++ b/apps/desktop/src/main/load-health.ts @@ -1,3 +1,4 @@ +import { release } from 'node:os' import { createLogger } from '@sim/logger' import type { BrowserWindow } from 'electron' import { type EventRecorder, scrubUrl } from '@/main/observability' @@ -6,6 +7,15 @@ 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 + +function hasLocalNetworkPrivacy(): boolean { + return ( + process.platform === 'darwin' && + Number.parseInt(release(), 10) >= FIRST_LOCAL_NETWORK_PRIVACY_DARWIN + ) +} export type LoadErrorKind = | 'offline' @@ -22,7 +32,7 @@ export type LoadErrorKind = */ export function classifyLoadError( errorCode: number, - platform: NodeJS.Platform = process.platform + 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. @@ -44,7 +54,7 @@ export function classifyLoadError( // 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. - if (errorCode === -109 && platform === 'darwin') { + if (errorCode === -109 && localNetworkPrivacy) { return 'local_network' } return 'unreachable'