From ece3d1c6d342cc85c1ec36b53605d3a00e55ee79 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 01:56:48 -0600 Subject: [PATCH 1/2] fix(desktop): stop offering an update track the app cannot switch to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings offered an "Update track" select that promised stable and nightly builds were interchangeable. They have not been since 7a612518b0 split their identities: nightly declares com.pylon.code.nightly, is named Pylon (Nightly), and keeps its data in ~/.pylon-code-nightly. Picking Nightly on a stable install pointed electron-updater at the nightly feed and offered a download that can never install. Squirrel.Mac validates an update against the running app's designated requirement, which pins the bundle identifier: Pylon (Alpha): identifier "com.pylon.code" and anchor apple generic ... Pylon (Nightly): identifier "com.pylon.code.nightly" and anchor apple ... So the install is refused, and MacUpdater.quitAndInstall then waits on a native update-downloaded event that never arrives — with the backends already stopped, which reads as the app hanging on restart. No second app appears in /Applications either, because Squirrel only ever replaces the running bundle. Windows and Linux fail differently for the same reason. Even a hypothetical success would relaunch against a different runtime home, so the user's projects would look erased. The track is now a property of the build, not a preference: DesktopUpdates derives it from the app version, and setChannel is gone from the service, the IPC method, the preload bridge, and the contract. updateChannel and updateChannelConfiguredByUser leave DesktopSettings; existing documents still decode (excess keys are ignored) and shed the keys on the next write, which is what unsticks anyone whose stable install is currently polling the nightly feed. Remote SSH environments pick their t3 dist-tag from the build for the same reason. The settings row keeps its place and tells the truth instead: which build you are on, that the other one installs alongside with its own data, and a button that opens its download page. Model: Claude Opus 5 via Claude Code. --- apps/desktop/src/app/DesktopEnvironment.ts | 2 +- .../src/backend/DesktopServerExposure.test.ts | 1 - apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 - apps/desktop/src/ipc/channels.ts | 1 - apps/desktop/src/ipc/methods/updates.ts | 11 -- apps/desktop/src/main.ts | 9 +- apps/desktop/src/preload.ts | 2 - .../src/settings/DesktopAppSettings.test.ts | 77 ++++---- .../src/settings/DesktopAppSettings.ts | 78 +------- .../src/updates/DesktopUpdates.test.ts | 186 +++++------------- apps/desktop/src/updates/DesktopUpdates.ts | 109 ++-------- .../desktop/src/updates/updatesTestHarness.ts | 49 +---- .../src/window/DesktopApplicationMenu.test.ts | 1 - apps/desktop/src/window/DesktopWindow.test.ts | 1 - apps/web/src/branding.ts | 7 + .../components/settings/SettingsPanels.tsx | 92 ++++----- docs/user/updating.md | 9 + packages/contracts/src/ipc.ts | 1 - 18 files changed, 168 insertions(+), 470 deletions(-) diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 7bfefb3c82..276727f0e0 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -277,7 +277,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( appImagePath: config.appImagePath, userDataDirName, legacyUserDataDirName, - defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), + defaultDesktopSettings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, processArch: input.processArch, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index eb0becee09..74b00956a4 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -253,7 +253,6 @@ describe("DesktopServerExposure", () => { setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), - setUpdateChannel: () => Effect.die("unexpected update channel change"), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 3e30083064..aa88cd7c26 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -29,7 +29,6 @@ import { downloadUpdate, getUpdateState, installUpdate, - setUpdateChannel, } from "./methods/updates.ts"; import { getAppBranding, @@ -98,7 +97,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); - yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5b2c815eaa..33ba264141 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -15,7 +15,6 @@ export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-c export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; -export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index b221260903..64bd64c18a 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -1,6 +1,5 @@ import { DesktopUpdateActionResultSchema, - DesktopUpdateChannelSchema, DesktopUpdateCheckResultSchema, DesktopUpdateStateSchema, } from "@t3tools/contracts"; @@ -21,16 +20,6 @@ export const getUpdateState = DesktopIpc.makeIpcMethod({ }), }); -export const setUpdateChannel = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, - payload: DesktopUpdateChannelSchema, - result: DesktopUpdateStateSchema, - handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { - const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.setChannel(channel); - }), -}); - export const downloadUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, payload: Schema.Void, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa9..640e9a7cc0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -51,6 +51,7 @@ import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import { resolveDefaultDesktopUpdateChannel } from "./updates/updateChannels.ts"; import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; @@ -86,7 +87,6 @@ const desktopEnvironmentLayer = Layer.unwrap( const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], - settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -98,7 +98,7 @@ const resolveDesktopSshCliRunner = ( return { packageSpec: resolveRemoteT3CliPackageSpec({ appVersion: environment.appVersion, - updateChannel: settings.updateChannel, + updateChannel: resolveDefaultDesktopUpdateChannel(environment.appVersion), isDevelopment: environment.isDevelopment, }), nodeEngineRange: serverPackageJson.engines.node, @@ -108,11 +108,8 @@ const resolveDesktopSshCliRunner = ( const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopSshEnvironment.layer({ - resolveCliRunner: settings.get.pipe( - Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), - ), + resolveCliRunner: Effect.sync(() => resolveDesktopSshCliRunner(environment)), }); }), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c93ee67b5c..1e8f18718f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -151,8 +151,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), - setUpdateChannel: (channel) => - ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749ab..cc323bce21 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -28,6 +28,8 @@ const DesktopSettingsPatch = Schema.Struct({ serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), + // Retired keys, kept here so tests can still write the legacy documents that + // real installs have on disk. updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), wslBackendEnabled: Schema.optionalKey(Schema.Boolean), @@ -101,25 +103,6 @@ describe("DesktopSettings", () => { ), ); - it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual( - DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), - { - linuxPasswordStore: "auto", - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings, - ); - }); - it.effect("loads persisted settings and applies semantic updates", () => withSettings( Effect.gen(function* () { @@ -129,8 +112,6 @@ describe("DesktopSettings", () => { serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, }); assert.deepEqual(yield* settings.load, { @@ -140,8 +121,6 @@ describe("DesktopSettings", () => { serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -157,11 +136,38 @@ describe("DesktopSettings", () => { }); assert.isTrue(tailscale.changed); assert.equal(tailscale.settings.tailscaleServePort, 9443); + }), + ), + ); - const updateChannel = yield* settings.setUpdateChannel("nightly"); - assert.isTrue(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannel, "nightly"); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + // Anyone who used the old Update track selector has `updateChannel` on disk. + // Honouring it would leave a stable install polling the nightly feed for an + // update it can never install, so the key has to stay ignored. + it.effect("ignores a retired update channel preference and drops it on write", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + tailscaleServePort: 8443, + updateChannel: "nightly", + updateChannelConfiguredByUser: true, + }); + + const loaded = yield* settings.load; + assert.deepEqual(loaded, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + tailscaleServePort: 8443, + } satisfies DesktopAppSettings.DesktopSettings); + assert.notProperty(loaded, "updateChannel"); + + yield* settings.setServerExposureMode("network-accessible"); + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + ); + assert.isUndefined(persisted.updateChannel); + assert.isUndefined(persisted.updateChannelConfiguredByUser); }), ), ); @@ -200,10 +206,6 @@ describe("DesktopSettings", () => { port: Option.none(), }); assert.isFalse(tailscale.changed); - - const updateChannel = yield* settings.setUpdateChannel("latest"); - assert.isFalse(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); }), ), ); @@ -247,8 +249,6 @@ describe("DesktopSettings", () => { serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -303,8 +303,6 @@ describe("DesktopSettings", () => { serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, - updateChannel: "nightly", - updateChannelConfiguredByUser: true, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -341,7 +339,6 @@ describe("DesktopSettings", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ serverExposureMode: "local-only", - updateChannel: "latest", }); assert.deepEqual(yield* settings.load, { @@ -351,8 +348,6 @@ describe("DesktopSettings", () => { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -368,8 +363,6 @@ describe("DesktopSettings", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ serverExposureMode: "local-only", - updateChannel: "latest", - updateChannelConfiguredByUser: true, }); assert.deepEqual(yield* settings.load, { @@ -379,8 +372,6 @@ describe("DesktopSettings", () => { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -406,8 +397,6 @@ describe("DesktopSettings", () => { serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index aefc675255..3fd372fa3f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,8 +1,6 @@ import { DesktopServerExposureModeSchema, - DesktopUpdateChannelSchema, type DesktopServerExposureMode, - type DesktopUpdateChannel, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; @@ -21,7 +19,6 @@ import { normalizeLinuxPasswordStorePreference, type LinuxPasswordStorePreference, } from "../linuxSecretStorage.ts"; -import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { @@ -31,8 +28,6 @@ export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; - readonly updateChannel: DesktopUpdateChannel; - readonly updateChannelConfiguredByUser: boolean; // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL // integration. We now run Windows and WSL backends side by side, so the // setting is just whether the WSL backend should be running alongside the @@ -79,8 +74,6 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, - updateChannel: "latest", - updateChannelConfiguredByUser: false, wslBackendEnabled: false, wslDistro: null, wslOnly: false, @@ -100,8 +93,6 @@ const DesktopSettingsDocument = Schema.Struct({ serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), - updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), // Newer form of the WSL toggle. `wslMode` is still accepted on load so // existing on-disk settings keep working; on the next persist we write the // new boolean and the legacy key drops out. @@ -109,6 +100,11 @@ const DesktopSettingsDocument = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + // `updateChannel` and `updateChannelConfiguredByUser` used to live here, back + // when the update track was a preference. Stable and nightly are separate + // applications now, so a build's track is fixed by the build. Documents still + // carrying the old keys decode fine (excess keys are ignored) and shed them on + // the next write. }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -163,9 +159,6 @@ export class DesktopAppSettings extends Context.Service< readonly enabled: boolean; readonly port: Option.Option; }) => Effect.Effect; - readonly setUpdateChannel: ( - channel: DesktopUpdateChannel, - ) => Effect.Effect; readonly setWslBackendEnabled: ( enabled: boolean, ) => Effect.Effect; @@ -183,13 +176,6 @@ export class DesktopAppSettings extends Context.Service< } >()("@t3tools/desktop/settings/DesktopAppSettings") {} -export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { - return { - ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), - }; -} - function normalizeTailscaleServePort(value: unknown): number { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 ? value @@ -204,17 +190,8 @@ export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | return Option.getOrNull(decodeDesktopWindowBounds(value)); } -function normalizeDesktopSettingsDocument( - parsed: DesktopSettingsDocument, - appVersion: string, -): DesktopSettings { - const defaultSettings = resolveDefaultDesktopSettings(appVersion); +function normalizeDesktopSettingsDocument(parsed: DesktopSettingsDocument): DesktopSettings { const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); - const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); - const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; - const updateChannelConfiguredByUser = - parsed.updateChannelConfiguredByUser === true || - (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); // Newer form wins when both are present; otherwise fall back to the legacy // `wslMode === "wsl"` signal so users coming off the swap-mode build keep @@ -231,10 +208,6 @@ function normalizeDesktopSettingsDocument( parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), - updateChannel: updateChannelConfiguredByUser - ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) - : defaultSettings.updateChannel, - updateChannelConfiguredByUser, wslBackendEnabled, wslDistro: normalizeWslDistro(parsed.wslDistro), wslOnly: parsed.wslOnly === true, @@ -265,12 +238,6 @@ function toDesktopSettingsDocument( if (settings.tailscaleServePort !== defaults.tailscaleServePort) { document.tailscaleServePort = settings.tailscaleServePort; } - if (settings.updateChannel !== defaults.updateChannel) { - document.updateChannel = settings.updateChannel; - } - if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { - document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; - } if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) { document.wslBackendEnabled = settings.wslBackendEnabled; } @@ -329,19 +296,6 @@ function setTailscaleServe( }; } -function setUpdateChannel( - settings: DesktopSettings, - requestedChannel: DesktopUpdateChannel, -): DesktopSettings { - return settings.updateChannel === requestedChannel - ? settings - : { - ...settings, - updateChannel: requestedChannel, - updateChannelConfiguredByUser: true, - }; -} - function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { return settings.wslBackendEnabled === enabled ? settings @@ -377,19 +331,16 @@ function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, - appVersion: string, ): Effect.Effect { - const defaultSettings = resolveDefaultDesktopSettings(appVersion); - return fileSystem.readFileString(settingsPath).pipe( Effect.option, Effect.flatMap( Option.match({ - onNone: () => Effect.succeed(defaultSettings), + onNone: () => Effect.succeed(DEFAULT_DESKTOP_SETTINGS), onSome: (raw) => decodeDesktopSettingsJson(raw).pipe( - Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), - Effect.orElseSucceed(() => defaultSettings), + Effect.map(normalizeDesktopSettingsDocument), + Effect.orElseSucceed(() => DEFAULT_DESKTOP_SETTINGS), ), }), ), @@ -499,11 +450,7 @@ export const make = Effect.gen(function* () { return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: Effect.gen(function* () { - const settings = yield* readSettings( - fileSystem, - environment.desktopSettingsPath, - environment.appVersion, - ); + const settings = yield* readSettings(fileSystem, environment.desktopSettingsPath); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), setMainWindowBounds: (bounds, isMaximized) => @@ -526,10 +473,6 @@ export const make = Effect.gen(function* () { persist((settings) => setTailscaleServe(settings, input)).pipe( Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), - ), setWslBackendEnabled: (enabled) => persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), @@ -580,7 +523,6 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), - setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), setWslBackendEnabled: (enabled) => update((settings) => setWslBackendEnabled(settings, enabled)), setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5097785215..9bb904b2b2 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -13,12 +13,51 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; -import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { + // Stable and nightly are separate applications with their own bundle ids and + // runtime homes, so the channel a build follows is a property of the build. + // It is never a preference, because no in-place update can turn one into the + // other. + it.effect("follows the stable feed on a stable build", () => + Effect.gen(function* () { + const harness = makeHarness({ appVersion: "1.2.3" }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.getState; + assert.equal(state.channel, "latest"); + assert.isFalse(harness.allowPrerelease()); + assert.isFalse(harness.fullChangelog()); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + + it.effect("follows the nightly feed on a nightly build", () => + Effect.gen(function* () { + const harness = makeHarness({ appVersion: "0.0.33-nightly.20260909.150" }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.getState; + assert.equal(state.channel, "nightly"); + assert.isTrue(harness.allowPrerelease()); + assert.isTrue(harness.fullChangelog()); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it("preserves complete causes for update poller and event failures", () => { const cause = Cause.combine( Cause.fail(new Error("updater failed")), @@ -129,14 +168,13 @@ describe("DesktopUpdates", () => { }); it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { - const harness = makeHarness(); + const harness = makeHarness({ appVersion: "1.2.4-nightly.20260709.700" }); return Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; - yield* updates.setChannel("nightly"); assert.equal(harness.fullChangelog(), true); harness.emit("update-available", { @@ -484,8 +522,8 @@ describe("DesktopUpdates", () => { assert.equal(failedState.errorContext, "download"); assert.equal(failedState.message, "Desktop update download action failed unexpectedly."); - const changedState = yield* updates.setChannel("nightly"); - assert.equal(changedState.channel, "nightly"); + const checkResult = yield* updates.check("manual"); + assert.isTrue(checkResult.checked); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -553,8 +591,8 @@ describe("DesktopUpdates", () => { assert.equal(failedState.errorContext, "install"); assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); - const changedState = yield* updates.setChannel("nightly"); - assert.equal(changedState.channel, "nightly"); + const checkResult = yield* updates.check("manual"); + assert.isTrue(checkResult.checked); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -672,138 +710,4 @@ describe("DesktopUpdates", () => { }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - - it.effect("persists channel changes through the settings service", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.setChannel("nightly"); - const persistedSettings = yield* settings.get; - - assert.equal(state.channel, "nightly"); - assert.equal(persistedSettings.updateChannel, "nightly"); - assert.equal(persistedSettings.updateChannelConfiguredByUser, true); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("does not persist an unchanged update channel as a user preference", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.setChannel("latest"); - const persistedSettings = yield* settings.get; - - assert.equal(state.channel, "latest"); - assert.equal(persistedSettings.updateChannel, "latest"); - assert.equal(persistedSettings.updateChannelConfiguredByUser, false); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("fails channel changes with a typed error while a check is in progress", () => - Effect.gen(function* () { - const checkStarted = yield* Deferred.make(); - const releaseCheck = yield* Deferred.make(); - const harness = makeHarness({ - checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCheck)), - ), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); - yield* Deferred.await(checkStarted); - - const exit = yield* Effect.exit(updates.setChannel("nightly")); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); - assert.equal(error.action, "check"); - assert.equal(error.requestedChannel, "nightly"); - } - - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(checkFiber); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); - - it.effect("rejects checks while an update channel change is being persisted", () => - Effect.gen(function* () { - const channelChangeStarted = yield* Deferred.make(); - const releaseChannelChange = yield* Deferred.make(); - const harness = makeHarness({ - beforeSetUpdateChannel: Deferred.succeed(channelChangeStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseChannelChange)), - ), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const channelFiber = yield* updates.setChannel("nightly").pipe(Effect.forkScoped); - yield* Deferred.await(channelChangeStarted); - - const checkResult = yield* updates.check("manual"); - assert.isFalse(checkResult.checked); - assert.equal(harness.checkCount(), 0); - - yield* Deferred.succeed(releaseChannelChange, undefined); - const state = yield* Fiber.join(channelFiber); - - assert.equal(state.channel, "nightly"); - assert.equal(harness.checkCount(), 1); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); - - it.effect("preserves settings failure context when an update channel cannot be persisted", () => { - const diskFailure = new Error("disk exploded"); - const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ - operation: "replace-settings-file", - path: "/tmp/settings.json", - cause: diskFailure, - }); - const harness = makeHarness({ setUpdateChannelError: settingsFailure }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const error = yield* updates.setChannel("nightly").pipe(Effect.flip); - - assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.equal(error.channel, "nightly"); - assert.strictEqual(error.cause, settingsFailure); - assert.strictEqual(error.cause.cause, diskFailure); - assert.equal(error.message, "Failed to persist the nightly desktop update channel."); - assert.notInclude(error.message, diskFailure.message); - - const checkResult = yield* updates.check("manual"); - assert.isTrue(checkResult.checked); - assert.equal(harness.checkCount(), 1); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 344d135a10..556327c359 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,5 +1,4 @@ import { - DesktopUpdateChannelSchema, type DesktopRuntimeInfo, type DesktopUpdateActionResult, type DesktopUpdateChannel, @@ -30,7 +29,6 @@ import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { @@ -50,7 +48,7 @@ const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); -type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel"; +type UpdateAction = "check" | "download" | "install" | "install-recovery"; interface DesktopPreparedUpdateInstallResult extends DesktopUpdateActionResult { readonly failed: boolean; @@ -76,30 +74,6 @@ const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressIn const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); -export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass()( - "DesktopUpdateActionInProgressError", - { - action: Schema.Literals(["check", "download", "install", "channel"]), - requestedChannel: DesktopUpdateChannelSchema, - }, -) { - override get message(): string { - return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`; - } -} - -export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass()( - "DesktopUpdateChannelPersistenceError", - { - channel: DesktopUpdateChannelSchema, - cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), - }, -) { - override get message(): string { - return `Failed to persist the ${this.channel} desktop update channel.`; - } -} - export class DesktopUpdatePollerError extends Schema.TaggedErrorClass()( "DesktopUpdatePollerError", { @@ -127,7 +101,7 @@ export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass()( "DesktopUpdaterReportedError", { - operation: Schema.Literals(["check", "download", "install", "channel", "background"]), + operation: Schema.Literals(["check", "download", "install", "background"]), cause: Schema.Defect(), }, ) { @@ -150,18 +124,12 @@ export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass< export type DesktopUpdateConfigureError = never; -export const DesktopUpdateSetChannelError = Schema.Union([ - DesktopUpdateActionInProgressError, - DesktopUpdateChannelPersistenceError, -]); -export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; - export class DesktopUpdates extends Context.Service< DesktopUpdates, { readonly getState: Effect.Effect; - /** True while a check, download, install, or channel change holds the - updater's single action reservation. */ + /** True while a check, download, or install holds the updater's single + action reservation. */ readonly isActionActive: Effect.Effect; /** True only while an install owns the updater action reservation. */ readonly isInstallActive: Effect.Effect; @@ -177,9 +145,6 @@ export class DesktopUpdates extends Context.Service< readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; - readonly setChannel: ( - channel: DesktopUpdateChannel, - ) => Effect.Effect; readonly check: (reason: string) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; @@ -279,7 +244,12 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; - const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + + // Stable and nightly ship as separate applications, each with its own bundle + // id and runtime home, so no in-place update can carry one across to the + // other. The track a build follows is therefore the build's own, never a + // preference: switching means installing the other application. + const buildChannel = resolveDefaultDesktopUpdateChannel(environment.appVersion); const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const activeUpdateActionRef = yield* Ref.make>(Option.none()); @@ -287,11 +257,7 @@ export const make = Effect.gen(function* () { const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const updateStateRef = yield* Ref.make( - createInitialDesktopUpdateState( - environment.appVersion, - environment.runtimeInfo, - environment.defaultDesktopSettings.updateChannel, - ), + createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, buildChannel), ); const stateChanges = yield* PubSub.sliding(16); @@ -355,12 +321,6 @@ export const make = Effect.gen(function* () { Option.isSome(activeAction) ? [false, activeAction] : [true, Option.some(action)], ); - const tryStartChannelChange = Ref.modify(activeUpdateActionRef, (activeAction) => - Option.isSome(activeAction) - ? [activeAction, activeAction] - : [Option.none(), Option.some("channel")], - ); - const finishUpdateAction = (action: UpdateAction): Effect.Effect => Ref.modify(activeUpdateActionRef, (activeAction) => { const finished = Option.isSome(activeAction) && activeAction.value === action; @@ -875,9 +835,8 @@ export const make = Effect.gen(function* () { } as ElectronUpdater.ElectronUpdaterFeedUrl); } - const settings = yield* desktopSettings.get; const enabled = yield* shouldEnableAutoUpdates; - yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); + yield* setState(createBaseUpdateState(buildChannel, enabled, environment)); if (!enabled) { return; } @@ -885,7 +844,7 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setAutoDownload(false); yield* electronUpdater.setAutoInstallOnAppQuit(false); - yield* applyAutoUpdaterChannel(settings.updateChannel); + yield* applyAutoUpdaterChannel(buildChannel); yield* electronUpdater.setDisableDifferentialDownload( isArm64HostRunningIntelBuild(environment.runtimeInfo), ); @@ -921,48 +880,6 @@ export const make = Effect.gen(function* () { yield* startUpdatePollers; }).pipe(Effect.withSpan("desktop.updates.configure")), - setChannel: Effect.fn("desktop.updates.setChannel")(function* ( - nextChannel: DesktopUpdateChannel, - ) { - yield* Effect.annotateCurrentSpan({ channel: nextChannel }); - const activeAction = yield* tryStartChannelChange; - if (Option.isSome(activeAction)) { - return yield* new DesktopUpdateActionInProgressError({ - action: activeAction.value === "install-recovery" ? "install" : activeAction.value, - requestedChannel: nextChannel, - }); - } - - return yield* Effect.gen(function* () { - const state = yield* Ref.get(updateStateRef); - if (nextChannel === state.channel) { - return state; - } - - yield* desktopSettings - .setUpdateChannel(nextChannel) - .pipe( - Effect.mapError( - (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }), - ), - ); - - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); - - if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { - return yield* Ref.get(updateStateRef); - } - - yield* applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = yield* electronUpdater.allowDowngrade; - yield* electronUpdater.setAllowDowngrade(true); - yield* checkForUpdates("channel-change", "held").pipe( - Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), - ); - return yield* Ref.get(updateStateRef); - }).pipe(Effect.ensuring(finishUpdateAction("channel"))); - }), check: Effect.fn("desktop.updates.check")(function* (reason: string) { yield* Effect.annotateCurrentSpan({ reason }); if (!(yield* Ref.get(updaterConfiguredRef))) { diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 53a6dc97f5..7afa00e4b6 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -24,14 +24,14 @@ export interface UpdatesHarnessOptions { void, ElectronUpdater.ElectronUpdaterCheckForUpdatesError >; - readonly beforeSetUpdateChannel?: Effect.Effect; - readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; readonly downloadUpdate?: Effect.Effect; readonly quitAndInstall?: Effect.Effect; readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; readonly env?: Record; + /** Drives the update channel: nightly versions select the nightly feed. */ + readonly appVersion?: string; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -39,6 +39,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let quitAndInstallCount = 0; let downloadCount = 0; let allowDowngrade = false; + let allowPrerelease = false; let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); @@ -70,7 +71,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setAutoDownload: () => Effect.void, setAutoInstallOnAppQuit: () => Effect.void, setChannel: () => Effect.void, - setAllowPrerelease: () => Effect.void, + setAllowPrerelease: (value) => + Effect.sync(() => { + allowPrerelease = value; + }), allowDowngrade: Effect.sync(() => allowDowngrade), setAllowDowngrade: (value) => Effect.sync(() => { @@ -146,7 +150,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, platform: "darwin", processArch: "x64", - appVersion: "1.2.3", + appVersion: options.appVersion ?? "1.2.3", appPath: "/repo", isPackaged: true, resourcesPath: "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/missing/resources", @@ -165,41 +169,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ), ); - let testSettings: DesktopAppSettings.DesktopSettings = { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - }; - const setUpdateChannelError = options.setUpdateChannelError; - const settingsLayer = - setUpdateChannelError || options.beforeSetUpdateChannel - ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.sync(() => testSettings), - load: Effect.sync(() => testSettings), - setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), - setServerExposureMode: () => Effect.die("unexpected server exposure update"), - setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), - setUpdateChannel: (channel) => - setUpdateChannelError - ? Effect.fail(setUpdateChannelError) - : (options.beforeSetUpdateChannel ?? Effect.void).pipe( - Effect.andThen( - Effect.sync(() => { - const changed = testSettings.updateChannel !== channel; - testSettings = { - ...testSettings, - updateChannel: channel, - updateChannelConfiguredByUser: true, - }; - return { settings: testSettings, changed }; - }), - ), - ), - setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), - setWslDistro: () => Effect.die("unexpected WSL distro change"), - setWslOnly: () => Effect.die("unexpected WSL-only toggle"), - applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), - applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), - } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) - : DesktopAppSettings.layer; + const settingsLayer = DesktopAppSettings.layer; const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), @@ -226,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { installSteps, downloadCount: () => downloadCount, feedUrls: () => feedUrls, + allowPrerelease: () => allowPrerelease, fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 5255455707..69d6a410be 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -66,7 +66,6 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { emitState: Effect.void, disabledReason: Effect.succeed(Option.none()), configure: Effect.void, - setChannel: () => Effect.die("unexpected setChannel"), check: () => Effect.die("unexpected check"), download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 52130bcc1a..51013aba2c 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -236,7 +236,6 @@ function makeTestLayer(input: { }), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), - setUpdateChannel: () => Effect.die("unexpected update channel change"), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index f8de193f40..34f382a347 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -25,3 +25,10 @@ export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; + +/** Download page for a desktop build, per release track. */ +export function desktopDownloadUrl(channel: "latest" | "nightly"): string { + return channel === "nightly" + ? "https://pylon-code.com/download?channel=nightly" + : "https://pylon-code.com/download"; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ccc02675bc..4f18f50c06 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -44,7 +44,12 @@ import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; -import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; +import { + APP_VERSION, + HOSTED_APP_CHANNEL, + HOSTED_APP_CHANNEL_LABEL, + desktopDownloadUrl, +} from "../../branding"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -240,42 +245,32 @@ function AboutVersionTitle() { function AboutVersionSection() { const updateState = useDesktopUpdateState(); - const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); const [isUpdateActionPending, setIsUpdateActionPending] = useState(false); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; - const handleUpdateChannelChange = useCallback( - (channel: DesktopUpdateChannel) => { - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.setUpdateChannel !== "function" || - channel === selectedUpdateChannel - ) { - return; - } + // Stable and nightly are separate applications, each keeping its own projects + // and settings, so the other track is something you install alongside this + // one rather than something this app can switch itself to. + const otherUpdateChannel: DesktopUpdateChannel = + selectedUpdateChannel === "nightly" ? "latest" : "nightly"; - setIsChangingUpdateChannel(true); - void bridge - .setUpdateChannel(channel) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not change update track", - description: error instanceof Error ? error.message : "Update track change failed.", - }), - ); - }) - .finally(() => { - setIsChangingUpdateChannel(false); - }); - }, - [selectedUpdateChannel], - ); + const handleGetOtherChannelBuild = useCallback(() => { + const api = readLocalApi(); + if (!api) return; + + void api.shell.openExternal(desktopDownloadUrl(otherUpdateChannel)).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open the download page", + description: error instanceof Error ? error.message : "Opening the link failed.", + }), + ); + }); + }, [otherUpdateChannel]); const handleButtonClick = useCallback(async () => { const bridge = window.desktopBridge; @@ -409,33 +404,20 @@ function AboutVersionSection() { {hasDesktopBridge ? ( { - handleUpdateChannelChange(value as DesktopUpdateChannel); - }} + } /> ) : selectedHostedAppChannel ? ( diff --git a/docs/user/updating.md b/docs/user/updating.md index 7a8a093001..8796c483d3 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -10,6 +10,15 @@ Stable selects the latest stable release; Nightly selects a recent preview build links and release notes follow your choice. You can share a direct [Nightly download link](https://pylon-code.com/download?channel=nightly). +Stable and Nightly are two separate applications. Installing one leaves the other in place, and +each keeps its own projects, threads, and settings — nothing carries across, and neither one +updates into the other. Each app keeps itself current on the track it was built for. + +To move between tracks, install the other app: **Settings** → **About** → **Update track** shows +which build you are running and opens the download page for the other one. Both can run at the +same time. If you want to leave a track for good, delete that app; its data stays on disk until +you remove it yourself. + Mobile availability is separate from the desktop release channel. ## Where to Find the Update diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7d28d9236f..80eba16017 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1151,7 +1151,6 @@ export interface DesktopBridge { getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; - setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; From 23cfceb3fbaf4b567b0437c1f50ca5378fb2aeb6 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 01:57:09 -0600 Subject: [PATCH 2/2] docs(agents): record that the desktop update track is fixed by the build --- .agents/durable-facts.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/durable-facts.jsonl b/.agents/durable-facts.jsonl index 0f3a3f78d4..3e35c85c0a 100644 --- a/.agents/durable-facts.jsonl +++ b/.agents/durable-facts.jsonl @@ -12,3 +12,4 @@ {"id":"2026-08-30-prime-correlated-prompt-lifecycle","recorded_at":"2026-08-30T01:34:28Z","scope":"provider/prime-agent","fact":"Prime foreground prompt ownership has two explicit modes and is never inferred from text or generic busy state. The optional correlated lifecycle is enabled only by explicit capability negotiation; its correlation stays provider-private and generation-bound, and recovery requires complete attributed event continuity without synthesizing snapshot output. Stock daemons use the conservative observed-activity busy path.","source_commits":["058193588f66d8b455cd49e9b014bc9fde4b0447","9a4de4c08ee62dea2b5ad68ff8bf022addb67d61","486af3ea52180c3dcda51b6411ffaeecf9987d08"],"source_paths":["docs/internals/prime-agent-daemon-parity.md","docs/user/providers-prime-agent.md","apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts","apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts","apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts"],"supersedes":[]} {"id":"2026-08-30-runtime-home-default","recorded_at":"2026-08-31T14:45:36Z","scope":"server/runtime-home","fact":"Every Pylon server launch path defaults its runtime home to ~/.pylon-code: the t3 CLI's resolveBaseDir, the desktop's SSH remote launcher scripts, the WSL secondary backend, and the dev runner's implicit home. ~/.t3 is T3 Code's install and is never adopted as a fallback, because its database carries upstream migration numbering. --base-dir and T3CODE_HOME remain the unchanged, T3-compatible overrides. When a launch resolves the default base dir, finds no state there, and sees an older ~/.t3/userdata, warnAboutLegacyRuntimeHome writes one hint to stderr and never to the logger: auth, connect, and project resolve their base dir through the same helper and emit JSON payloads on the logger's stream, and their quietLogs guard is installed too late to suppress it. Migration moves the userdata state directory, not its parent: settings.json and secrets are derived from stateDir and live inside userdata, while the sibling caches and worktrees directories are disposable or referenced by absolute path and must stay. The SSH launch script names ~/.t3 only to retire a previous launcher's state directory once, and signals a recorded pid only after confirming its process arguments still carry serve --host 127.0.0.1 and --base-dir $HOME/.t3; where ps cannot report arguments it leaves the process running and still retires the directory. The maintainer database scripts keep their source home and their destructive-write guard separate: both read or seed from ~/.pylon-code but refuse to write to either runtime home, and migrate-dev-db applies that refusal before it checks whether a source exists.","source_commits":["a5cfa5da266c3e10ff5b7b049aea8035c222282a","8ec50a068a278b385428b5c131bf093ecaf7079c","ec31498eb69ebe7c6f15985ecb8320205a0ad872","5814f1a62a91d852b752562c39ad722323f999cc","fc7bda6e1fe7f058891d741ebea5116ce0ba4e37","1ace841de68d5e4563647182621d633a44025a81","8c298a75b153dba825114fe5601d26e2339c3f01","20bae41009f8cbb43ed7cb044ba024f5c6971ea5","a4ddf3266e6175de0cb76ffc5f8cc3ec3d180bac","66401f073eb502fd00e9719a18d6e8343cb500d9"],"source_paths":["apps/server/src/os-jank.ts","apps/server/src/os-jank.test.ts","apps/server/src/cli/config.ts","apps/server/src/cli/config.test.ts","apps/server/src/cli/triage.ts","packages/ssh/src/tunnel.ts","packages/ssh/src/tunnel.test.ts","apps/server/scripts/migrate-dev-db.ts","apps/server/scripts/t3-sqlite-state.ts","scripts/dev-runner.ts","docs/user/install.md","docs/user/remote-access.md","docs/operations/observability.md","docs/internals/scripts.md"],"supersedes":[]} {"id": "2026-09-09-client-settings-default-flip-needs-fresh-key", "recorded_at": "2026-09-09T07:25:11Z", "scope": "contracts/client-settings", "fact": "Changing a ClientSettings decoding default does not reach existing installs. DesktopClientSettings encodes the whole ClientSettingsSchema and writes it to /userdata/client-settings.json, so a saved document carries every schema field with an explicit value, including keys the user never touched; observed live, one such document held all 44 schema fields. A withDecodingDefault change therefore only affects installs whose stored document predates the key, and retired keys linger in older files. To move a default for everyone, retire the key and add a fresh one, because decoding drops unknown keys: contextWindowIndicatorEnabled replaced contextWindowMeterEnabled, and legacySidebarEnabled replaced sidebarV2Enabled plus sidebarV2ConfiguredByUser.", "source_commits": ["8d5acb2dfd3e3e899e977d5c964eb99d2f3c3ee1", "75afe0ae0e42a815af01dbdb89b55b317bbc2418"], "source_paths": ["packages/contracts/src/settings.ts", "packages/contracts/src/settings.test.ts", "apps/desktop/src/settings/DesktopClientSettings.ts"], "supersedes": []} +{"id": "2026-09-10-desktop-update-track-is-a-build-property", "recorded_at": "2026-09-10T09:05:00Z", "scope": "desktop/updates", "fact": "A desktop build's update track is fixed by the build and cannot be changed at runtime. Stable and nightly are separate applications: appId com.pylon.code vs com.pylon.code.nightly, productName Pylon (Alpha) vs Pylon (Nightly), runtime home ~/.pylon-code vs ~/.pylon-code-nightly. On macOS an in-place cross-track update is impossible, not merely undesirable: Squirrel.Mac validates the downloaded bundle against the running app's designated requirement, which pins the bundle identifier (verified on installed builds via `codesign -d --requirements -`), so the nightly bundle can never satisfy the stable app's requirement. A refused install is also silent \u2014 electron-updater's MacUpdater.quitAndInstall registers a listener for a native update-downloaded event that never fires, after DesktopUpdates has already stopped every backend, which presents as the app hanging on restart. Squirrel only ever replaces the running bundle, so no second app is ever produced. DesktopUpdates therefore derives its channel from resolveDefaultDesktopUpdateChannel(appVersion); there is no setChannel on the service, IPC, preload bridge, or contract, and DesktopSettings carries no updateChannel. Documents written by the retired Update track selector still decode (excess keys are ignored) and shed updateChannel and updateChannelConfiguredByUser on the next write, so a stable install that was switched to nightly returns to the stable feed on upgrade. Moving between tracks means installing the other application; both run at once.", "source_commits": ["ece3d1c6d342cc85c1ec36b53605d3a00e55ee79"], "source_paths": ["apps/desktop/src/updates/DesktopUpdates.ts", "apps/desktop/src/updates/updateChannels.ts", "apps/desktop/src/settings/DesktopAppSettings.ts", "apps/web/src/components/settings/SettingsPanels.tsx", "scripts/build-desktop-artifact.ts", "docs/user/updating.md"], "supersedes": []}