From c7605b18e88fc67cdb44bea042d2078e121ef0e6 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sat, 3 Oct 2026 09:08:35 -0600 Subject: [PATCH 1/3] fix(desktop): network access survives a launch before the network is up Launching at login can beat DHCP. The desktop then found no LAN address, fell back to binding 127.0.0.1, and stayed there until the next relaunch, which hid pairing in Settings and dropped every remote client. The saved network-access choice now always binds all interfaces, and the advertised host is re-read whenever Settings asks, so pairing links appear once an address does. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/app/DesktopApp.ts | 7 +-- .../src/backend/DesktopServerExposure.test.ts | 60 ++++++++++++------- .../src/backend/DesktopServerExposure.ts | 41 +++++++++---- 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 6ac1ea8c7754..59bb0af00ef7 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -225,12 +225,9 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap enabled network access", { endpointUrl: serverExposureState.endpointUrl, }); - } else if ( - settings.serverExposureMode === "network-accessible" && - serverExposureState.mode === "local-only" - ) { + } else if (serverExposureState.mode === "network-accessible") { yield* logBootstrapWarning( - "bootstrap fell back to local-only because no advertised network host was available", + "bootstrap enabled network access before any network address was available", ); } diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 99bd1948845d..990bd92815bd 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -86,9 +86,14 @@ function makeEnvironmentLayer(baseDir: string, env: Record DesktopNetworkInterfaces.NetworkInterfaces); + function makeLayer(input: { readonly baseDir: string; - readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; + readonly networkInterfaces?: NetworkInterfacesInput; readonly env?: Record; readonly spawnerLayer?: Layer.Layer; readonly desktopSettingsLayer?: Layer.Layer; @@ -96,7 +101,10 @@ function makeLayer(input: { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const environmentLayer = makeEnvironmentLayer(input.baseDir, env); const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { - read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), + read: Effect.sync(() => { + const networkInterfaces = input.networkInterfaces ?? emptyNetworkInterfaces; + return typeof networkInterfaces === "function" ? networkInterfaces() : networkInterfaces; + }), }); return DesktopServerExposure.layer.pipe( @@ -111,7 +119,7 @@ function makeLayer(input: { } const withHarness = ( - networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, + networkInterfaces: NetworkInterfacesInput, effect: Effect.Effect< A, E, @@ -144,25 +152,33 @@ const withHarness = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopServerExposure", () => { - it.effect("falls back to local-only without losing the requested network preference", () => - withHarness( - emptyNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - - yield* settings.setServerExposureMode("network-accessible"); - - const state = yield* serverExposure.configureFromSettings({ port: 4173 }); - assert.equal(state.mode, "local-only"); - assert.equal(state.endpointUrl, null); - assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); - - const backendConfig = yield* serverExposure.backendConfig; - assert.equal(backendConfig.bindHost, "127.0.0.1"); - assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); - }), - ), + it.effect( + "keeps network access requested at launch and advertises once an address appears", + () => { + let currentNetworkInterfaces = emptyNetworkInterfaces; + return withHarness( + () => currentNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "network-accessible"); + assert.equal(state.endpointUrl, null); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "0.0.0.0"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + + currentNetworkInterfaces = lanNetworkInterfaces; + const refreshed = yield* serverExposure.getState; + assert.equal(refreshed.endpointUrl, "http://192.168.1.20:4173"); + assert.equal(refreshed.advertisedHost, "192.168.1.20"); + }), + ); + }, ); it.effect("returns a typed error when network access is explicitly unavailable", () => diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 9a37d7311e6f..68ada845edfb 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -448,20 +448,14 @@ function resolveRuntimeState(input: { !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address), ), ); - const exposure = unavailable - ? resolveDesktopServerExposure({ - mode: "local-only", - port: input.port, - networkInterfaces: input.networkInterfaces, - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }) - : requestedExposure; + // An unavailable network keeps the requested binding: a launch at login can + // beat DHCP, and the advertised host is re-read once an address appears. return { state: runtimeStateFromResolvedExposure({ requestedMode: input.requestedMode, settings: input.settings, - exposure, + exposure: requestedExposure, port: input.port, }), unavailable, @@ -512,7 +506,32 @@ export const make = Effect.gen(function* () { ? cachedReadDefaultRouteInterface : Effect.succeed(null); - const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); + // Re-reads the advertised host so pairing links follow the network: an + // address that arrived after launch, or one DHCP changed while running. + const refreshAdvertisedHost = Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + if (state.mode !== "network-accessible") return state; + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const advertisedHostOverride = Option.getOrUndefined(config.desktopLanHostOverride); + const exposure = resolveDesktopServerExposure({ + mode: state.mode, + port: state.port, + networkInterfaces: currentNetworkInterfaces, + preferredInterface: yield* readPreferredInterface(state.mode, currentNetworkInterfaces), + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + return yield* Ref.updateAndGet(stateRef, (current) => + current.mode === state.mode && current.port === state.port + ? { + ...current, + endpointUrl: Option.fromNullishOr(exposure.endpointUrl), + advertisedHost: Option.fromNullishOr(exposure.advertisedHost), + } + : current, + ); + }); + + const getState = refreshAdvertisedHost.pipe(Effect.map(toContractState)); const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( @@ -613,7 +632,7 @@ export const make = Effect.gen(function* () { ); const getAdvertisedEndpoints = Effect.gen(function* () { - const state = yield* Ref.get(stateRef); + const state = yield* refreshAdvertisedHost; const currentNetworkInterfaces = yield* readNetworkInterfaces; const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, From 50933a14396bdfbc85721308ce5d9ba261c7b3d8 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sat, 3 Oct 2026 09:10:02 -0600 Subject: [PATCH 2/3] chore(release): PseudoCode 0.0.66 Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b73980a2869a..dc1325019c89 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.65", + "version": "0.0.66", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 74857a72cabc..1be39e2b11be 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.65", + "version": "0.0.66", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 4fc327da6ccd..a62a0fda4133 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.65", + "version": "0.0.66", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index efad79dc828c..571988f21a06 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.65", + "version": "0.0.66", "private": true, "files": [ "dist" From 6bd965ce08fc856b52ba34937966a5581592cc3f Mon Sep 17 00:00:00 2001 From: pseudo Date: Sat, 3 Oct 2026 09:14:18 -0600 Subject: [PATCH 3/3] fix(mobile): pin expo-modules-core to the patched 58.0.9 A tilde range let fresh resolution pick 58.0.11 once it aged past the release-age gate, leaving the 58.0.9 patch unused and failing Release Smoke. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index aa81ea6c23d7..c09f199eacdc 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -98,7 +98,7 @@ "expo-image-picker": "~58.0.9", "expo-keep-awake": "~58.0.2", "expo-linking": "~58.0.9", - "expo-modules-core": "~58.0.9", + "expo-modules-core": "58.0.9", "expo-network": "~58.0.2", "expo-notifications": "~58.0.9", "expo-paste-input": "^0.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab7bb5d4fd21..c35965b11e23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,7 +396,7 @@ importers: specifier: ~58.0.9 version: 58.0.9(expo@58.0.0)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) expo-modules-core: - specifier: ~58.0.9 + specifier: 58.0.9 version: 58.0.9(patch_hash=562a37a6427b3122a9b77c9beaa99eee06f372550cbcbfb631c1a393a5dbe81b)(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) expo-network: specifier: ~58.0.2