From ea80cb52cf426b609ea82c845f68545346c02975 Mon Sep 17 00:00:00 2001 From: trahloc Date: Sat, 7 Nov 2026 10:54:22 +0100 Subject: [PATCH 01/14] fix: coordinate desktop update entrypoints --- apps/desktop/src/app/DesktopApp.test.ts | 2 + .../src/app/DesktopAppActivation.test.ts | 43 +++- apps/desktop/src/app/DesktopAppActivation.ts | 30 ++- apps/desktop/src/app/DesktopCliShim.test.ts | 2 +- apps/desktop/src/app/DesktopCliShim.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 10 + apps/desktop/src/ipc/channels.ts | 7 + apps/desktop/src/ipc/methods/updates.ts | 47 ++++ apps/desktop/src/main.ts | 13 +- apps/desktop/src/preload.ts | 18 ++ .../src/updates/DesktopRemoteUpdates.test.ts | 14 +- .../src/updates/DesktopRemoteUpdates.ts | 27 +- .../src/updates/DesktopUpdateCommand.test.ts | 233 ++++++++++++++++++ .../src/updates/DesktopUpdateCommand.ts | 195 +++++++++++++++ .../src/window/DesktopApplicationMenu.test.ts | 12 + .../src/window/DesktopApplicationMenu.ts | 10 + apps/server/src/binMain.ts | 2 +- apps/server/src/cli/app.test.ts | 76 +++++- apps/server/src/cli/app.ts | 22 +- apps/server/src/cli/desktopUpdate.ts | 72 ++++++ apps/server/src/cli/service.test.ts | 66 +++-- apps/server/src/cli/service.ts | 30 ++- apps/server/src/cli/update.ts | 118 +++++++-- apps/server/src/cliCompileCache.test.ts | 9 + .../DesktopUpdateCoordinator.browser.test.tsx | 83 +++++++ .../desktop/DesktopUpdateCoordinator.tsx | 162 ++++++++++++ .../desktop/desktopUpdateRequest.ts | 19 ++ .../web/src/components/desktopUpdate.logic.ts | 7 - .../components/settings/SettingsPanels.tsx | 53 +--- .../settings/UpdateTrackSwitchDialog.tsx | 101 ++++---- .../SidebarUpdatePill.browser.test.tsx | 49 +++- .../components/sidebar/SidebarUpdatePill.tsx | 59 +---- apps/web/src/routes/__root.tsx | 2 + docs/user/updating.md | 24 ++ .../contracts/src/desktopAppActivation.ts | 43 ++++ packages/contracts/src/ipc.ts | 10 + 36 files changed, 1419 insertions(+), 253 deletions(-) create mode 100644 apps/desktop/src/updates/DesktopUpdateCommand.test.ts create mode 100644 apps/desktop/src/updates/DesktopUpdateCommand.ts create mode 100644 apps/server/src/cli/desktopUpdate.ts create mode 100644 apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx create mode 100644 apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx create mode 100644 apps/web/src/components/desktop/desktopUpdateRequest.ts diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 8b5f9f712dc1..7c3e634954f6 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -1,3 +1,4 @@ +import * as DesktopUpdateCommand from "../updates/DesktopUpdateCommand.ts"; import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Deferred from "effect/Deferred"; @@ -212,6 +213,7 @@ it.effect.each([false, true])( Effect.provide( Layer.mergeAll( Layer.mock(BrowserImport.BrowserImport, {}), + Layer.mock(DesktopUpdateCommand.DesktopUpdateCommand, {}), Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration, {}), Layer.mock(DesktopCliCommand.DesktopCliCommand, {}), Layer.mock(DesktopClientSettings.DesktopClientSettings, {}), diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index 2bf1e716a99a..405f3b03e78e 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -63,7 +63,7 @@ function startOkServer(target: ReturnType, userId: number | u }); } -function exchange(address: string, payload: DesktopAppActivationRequest) { +function exchange(address: string, payload: unknown) { return new Promise((resolve, reject) => { const socket = NodeNet.createConnection(address); socket.setEncoding("utf8"); @@ -81,6 +81,47 @@ function exchange(address: string, payload: DesktopAppActivationRequest) { } describe("desktop app control server", () => { + it.effect("routes a read-only update check without activating a workspace", () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-update-check-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async () => { + throw new Error("A check must not activate a workspace"); + }, + handleUpdate: async (input) => ({ + version: 1, + requestId: input.requestId, + ok: true, + check: { + schema: "t3.update-check.v1", + coordinated: true, + owner: "desktop", + pathway: "desktop-coordinated", + currentVersion: "0.1.7", + targetVersion: "0.1.8", + state: "ready", + }, + }), + cancel: () => undefined, + onReclaimError: () => undefined, + }); + openServers.push(server); + const response = await exchange(target.address, { + version: 1, + requestId: "update-check", + type: "update-check", + }); + expect(response).toMatchObject({ ok: true, check: { coordinated: true, state: "ready" } }); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); it.effect("cancels a queued request when the client disconnects", () => Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 094033192e08..342db31baae9 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -10,6 +10,9 @@ import * as NodePath from "node:path"; import { DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, DesktopAppActivationRequest, + DesktopUpdateCommandRequest, + type DesktopUpdateCommandResponse, + type DesktopAppControlResponse, type DesktopAppActivationResponse, } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; @@ -26,6 +29,7 @@ import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopUpdateCommand from "../updates/DesktopUpdateCommand.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -34,6 +38,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; const MAX_REQUEST_BYTES = 64 * 1024; const REQUEST_TIMEOUT_MS = 15_000; const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest); +const isDesktopUpdateCommandRequest = Schema.is(DesktopUpdateCommandRequest); export class DesktopAppActivationStartError extends Schema.TaggedError()( "DesktopAppActivationStartError", @@ -110,6 +115,10 @@ export async function startDesktopAppControlServer(input: { readonly directory: string | null; readonly userId: number | undefined; readonly handle: (request: DesktopAppActivationRequest) => Promise; + readonly cancelUpdate?: (requestId: string) => void; + readonly handleUpdate?: ( + request: DesktopUpdateCommandRequest, + ) => Promise; readonly cancel: (requestId: string) => void; readonly onReclaimError: (error: unknown) => void; }): Promise { @@ -124,7 +133,7 @@ export async function startDesktopAppControlServer(input: { socket.setTimeout(5_000, () => socket.destroy()); - const finish = (response: DesktopAppActivationResponse) => { + const finish = (response: DesktopAppControlResponse) => { responseSent = true; if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`); }; @@ -151,6 +160,15 @@ export async function startDesktopAppControlServer(input: { return; } + if (isDesktopUpdateCommandRequest(parsed) && input.handleUpdate) { + activeRequestId = parsed.requestId; + void input + .handleUpdate(parsed) + .then(finish, () => + finish(invalidResponse(parsed.requestId, "The desktop update request failed.")), + ); + return; + } if (!isDesktopAppActivationRequest(parsed)) { pipe( requestIdFromUnknown(parsed), @@ -169,7 +187,10 @@ export async function startDesktopAppControlServer(input: { socket.on("error", () => socket.destroy()); socket.on("close", () => { sockets.delete(socket); - if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId); + if (!responseSent && activeRequestId !== null) { + input.cancel(activeRequestId); + input.cancelUpdate?.(activeRequestId); + } }); }; @@ -298,6 +319,7 @@ const { logWarning } = makeComponentLogger("desktop-app-activation"); export const make = Effect.gen(function* () { const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const updateCommand = yield* DesktopUpdateCommand.DesktopUpdateCommand; const electronWindow = yield* ElectronWindow.ElectronWindow; const path = yield* Path.Path; const userId = yield* HostProcess.UserId; @@ -338,6 +360,10 @@ export const make = Effect.gen(function* () { ...address, userId, handle: (request) => broker.request(request), + handleUpdate: (request) => runPromise(updateCommand.request(request)), + cancelUpdate: (requestId) => { + void runPromise(updateCommand.cancel(requestId)); + }, cancel: (requestId) => broker.cancel(requestId), onReclaimError: (cause) => void runPromise( diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index 50fcb08d9c64..924fd0ed44d8 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -35,7 +35,7 @@ const writeShim = (root: string, target: CliShimTarget) => { const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = {}) => NodeChildProcess.spawnSync(shim, args, { encoding: "utf8", - env: { PATH: process.env.PATH, ...env }, + env: { PATH: process.env.PATH, TMPDIR: NodePath.dirname(shim), ...env }, }); const REPORTER = diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 3d4b94abff46..31a1eb50103b 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -50,6 +50,7 @@ export const renderCliShim = (input: { ] : []), `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`, + 'set "T3CODE_DESKTOP_CLI=1"', `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`, 'set "ELECTRON_RUN_AS_NODE=1"', // Electron's Windows executable starts without stdin; this tells the CLI to reopen the console. @@ -71,6 +72,7 @@ export const renderCliShim = (input: { "#!/bin/sh", `# ${MARKER}`, `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`, + "export T3CODE_DESKTOP_CLI=1", `home=${shellWord(input.t3Home)}`, 'export T3CODE_HOME="${T3CODE_HOME:-$home}"', "export ELECTRON_RUN_AS_NODE=1", diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 66c558e86fc8..c799ff749f99 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -31,6 +31,11 @@ import { getUpdateState, installUpdate, setUpdateChannel, + updateCommandReady, + updateCommandComplete, + updateCommandRun, + updateCommandReserve, + updateCommandFinish, } from "./methods/updates.ts"; import { getAppBranding, @@ -157,6 +162,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); + yield* ipc.handle(updateCommandReady); + yield* ipc.handle(updateCommandComplete); + yield* ipc.handle(updateCommandRun); + yield* ipc.handle(updateCommandReserve); + yield* ipc.handle(updateCommandFinish); yield* ipc.handle(getCliCommandState); yield* ipc.handle(installCliCommand); yield* ipc.handle(uninstallCliCommand); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 840e5f116800..ed7f3cbc91b7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -129,3 +129,10 @@ export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider- export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; + +export const UPDATE_COMMAND_READY_CHANNEL = "desktop:update-command-ready"; +export const UPDATE_COMMAND_REQUEST_CHANNEL = "desktop:update-command-request"; +export const UPDATE_COMMAND_COMPLETE_CHANNEL = "desktop:update-command-complete"; +export const UPDATE_COMMAND_RUN_CHANNEL = "desktop:update-command-run"; +export const UPDATE_COMMAND_RESERVE_CHANNEL = "desktop:update-command-reserve"; +export const UPDATE_COMMAND_FINISH_CHANNEL = "desktop:update-command-finish"; diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index ca85ee8b0c41..7e67efdea81d 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -1,3 +1,5 @@ +import { DesktopUpdateCommandResponse } from "@t3tools/contracts"; +import * as DesktopUpdateCommand from "../../updates/DesktopUpdateCommand.ts"; import { DesktopUpdateActionResultSchema, DesktopUpdateChannelSchema, @@ -66,3 +68,48 @@ export const checkForUpdate = DesktopIpc.makeIpcMethod({ return yield* updates.check("web-ui"); }), }); + +export const updateCommandReady = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.UPDATE_COMMAND_READY_CHANNEL, + payload: Schema.Struct({ ready: Schema.Boolean, rendererId: Schema.String }), + result: Schema.Void, + handler: Effect.fn("desktop.ipc.updateCommand.ready")(function* ({ ready, rendererId }) { + yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).setReady(ready, rendererId); + }), +}); +export const updateCommandComplete = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.UPDATE_COMMAND_COMPLETE_CHANNEL, + payload: DesktopUpdateCommandResponse, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.updateCommand.complete")(function* (response) { + return yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).complete(response); + }), +}); +export const updateCommandRun = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.UPDATE_COMMAND_RUN_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateCommandResponse, + handler: Effect.fn("desktop.ipc.updateCommand.run")(function* () { + return yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).request({ + version: 1, + type: "update", + requestId: "desktop-update", + }); + }), +}); +export const updateCommandReserve = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.UPDATE_COMMAND_RESERVE_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.updateCommand.reserve")(function* () { + return yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).reserve; + }), +}); +export const updateCommandFinish = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.UPDATE_COMMAND_FINISH_CHANNEL, + payload: Schema.String, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.updateCommand.finish")(function* (reservation) { + yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).finish(reservation); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d109518be15e..70f2077e95bd 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import * as DesktopUpdateCommand from "./updates/DesktopUpdateCommand.ts"; import * as MacPermissions from "./permissions/MacPermissions.ts"; for (const stream of [process.stdout, process.stderr]) { stream.on("error", (err: NodeJS.ErrnoException) => { @@ -216,10 +217,14 @@ const layerDesktopApplication = Layer.mergeAll( ).pipe( Layer.provideMerge(layerDesktopSnapShot), Layer.provideMerge( - DesktopUpdates.layer.pipe( - Layer.provide(DesktopUpdatePolicy.layer), - // The same layer as the foundation's, so the policy is still read once. - Layer.provide(DesktopManagedPolicy.layer), + DesktopUpdateCommand.layer.pipe( + Layer.provideMerge( + DesktopUpdates.layer.pipe( + Layer.provide(DesktopUpdatePolicy.layer), + // The same layer as the foundation's, so the policy is still read once. + Layer.provide(DesktopManagedPolicy.layer), + ), + ), ), ), Layer.provideMerge(layerDesktopWslBackend), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2d2bd4f51648..dcee39db440c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -296,6 +296,24 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + updateCommand: { + setReady: (ready, rendererId) => + ipcRenderer.invoke(IpcChannels.UPDATE_COMMAND_READY_CHANNEL, { ready, rendererId }), + complete: (response) => + ipcRenderer.invoke(IpcChannels.UPDATE_COMMAND_COMPLETE_CHANNEL, response), + request: () => ipcRenderer.invoke(IpcChannels.UPDATE_COMMAND_RUN_CHANNEL), + reserve: () => ipcRenderer.invoke(IpcChannels.UPDATE_COMMAND_RESERVE_CHANNEL), + finish: (reservation) => + ipcRenderer.invoke(IpcChannels.UPDATE_COMMAND_FINISH_CHANNEL, reservation), + onRequest: (listener) => { + const wrapped = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request === "object" && request !== null) + listener(request as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.UPDATE_COMMAND_REQUEST_CHANNEL, wrapped); + return () => ipcRenderer.removeListener(IpcChannels.UPDATE_COMMAND_REQUEST_CHANNEL, wrapped); + }, + }, webLinks: { setReady: (ready) => ipcRenderer.invoke(IpcChannels.WEB_LINK_READY_CHANNEL, ready), onOpen: (listener) => { diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 1f5321148b77..d036cf2944b0 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -1,3 +1,4 @@ +import * as DesktopUpdateCommand from "./DesktopUpdateCommand.ts"; import { assert, describe, it } from "@effect/vitest"; import type { DesktopTelemetryRequestDesktopUpdate, @@ -74,7 +75,18 @@ function runRemoteUpdatesTest( yield* settle; yield* body({ reports, requests, commits, cancellations }); }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + ).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + harness.layer, + Layer.mock(DesktopUpdateCommand.DesktopUpdateCommand, { + reserve: Effect.succeed("test-reservation"), + finish: () => Effect.void, + }), + ), + ), + ); } function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateStatusReport[] { diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index 560e2e78d4bb..800a103dae0b 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -1,3 +1,4 @@ +import * as DesktopUpdateCommand from "./DesktopUpdateCommand.ts"; import type { DesktopTelemetryRequestDesktopUpdate, DesktopUpdateRemoteOutcome, @@ -48,11 +49,13 @@ export const listen: Effect.Effect< void, never, | DesktopUpdates.DesktopUpdates + | DesktopUpdateCommand.DesktopUpdateCommand | DesktopTelemetryPublisher.DesktopTelemetryPublisher | DesktopBackendPool.DesktopBackendPool | Scope.Scope > = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; const backends = yield* DesktopBackendPool.DesktopBackendPool; const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; const activeRequestIdRef = yield* Ref.make(Option.none()); @@ -135,6 +138,19 @@ export const listen: Effect.Effect< const handleRequest = (request: DesktopTelemetryRequestDesktopUpdate): Effect.Effect => Effect.scoped( Effect.gen(function* () { + const reservation = yield* command.reserve; + if (reservation === null) { + yield* publishReport( + yield* updates.getState, + { + outcome: "failed", + reason: "A desktop update or confirmation is already in progress.", + }, + request.requestId, + ); + return; + } + yield* Effect.addFinalizer(() => command.finish(reservation)); const cancellation = yield* Deferred.make(); const shouldStart = yield* Ref.modify(requestControlRef, (control) => { if (control.cancelledBeforeStart.includes(request.requestId)) { @@ -484,7 +500,16 @@ export const listen: Effect.Effect< } return; } - const result = yield* updates.installPrepared(claim.prepared.downloadedVersion); + const reservation = yield* command.reserve; + if (reservation === null) { + const reason = "A desktop update or confirmation is already in progress."; + yield* recordPreparedFailure(commit.requestId, reason); + yield* publishReport(current, { outcome: "failed", reason }, commit.requestId); + return; + } + const result = yield* updates + .installPrepared(claim.prepared.downloadedVersion) + .pipe(Effect.ensuring(command.finish(reservation))); if ( !result.accepted && result.state.downloadedVersion === claim.prepared.downloadedVersion && diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.test.ts b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts new file mode 100644 index 000000000000..039e3dcb6ed4 --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts @@ -0,0 +1,233 @@ +import { it, assert } from "@effect/vitest"; +import type { DesktopUpdateState, DesktopUpdateCommandRequest } from "@t3tools/contracts"; +import type { BrowserWindow } from "electron"; +import * as TestClock from "effect/testing/TestClock"; +import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopUpdateCommand from "./DesktopUpdateCommand.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; + +const state: DesktopUpdateState = { + enabled: true, + status: "available", + channel: "latest", + currentVersion: "0.1.7", + availableVersion: "0.1.8", + downloadedVersion: null, + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; +const layer = (send: (request: DesktopUpdateCommandRequest) => void) => + DesktopUpdateCommand.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(DesktopUpdates.DesktopUpdates, { + getState: Effect.succeed(state), + isActionActive: Effect.succeed(false), + }), + Layer.mock(ElectronWindow.ElectronWindow, { + main: Effect.succeedSome({ + webContents: { + isDestroyed: () => false, + send: (_channel: string, request: DesktopUpdateCommandRequest) => send(request), + }, + } as unknown as BrowserWindow), + }), + ), + ), + ); + +it.effect("checks cached coordinator availability without activating, checking or updating", () => + Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + assert.equal((yield* command.inspect).state, "unavailable"); + yield* command.setReady(true, "renderer"); + yield* command.setReady(false, "old-renderer"); + assert.deepInclude(yield* command.inspect, { + coordinated: true, + state: "ready", + currentVersion: "0.1.7", + targetVersion: "0.1.8", + }); + const first = yield* command.reserve; + assert.isNotNull(first); + assert.equal((yield* command.inspect).state, "busy"); + yield* command.finish(first!); + const second = yield* command.reserve; + assert.isNotNull(second); + yield* command.finish(first!); + assert.equal((yield* command.inspect).state, "busy"); + yield* command.finish(second!); + }).pipe( + Effect.provide( + layer(() => { + throw new Error("Read-only check dispatched UI"); + }), + ), + ), +); +it.effect("holds one reservation across confirmation and ignores a cancelled confirmation", () => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + yield* Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + yield* command.setReady(true, "renderer"); + const first = yield* command + .request({ version: 1, requestId: "first", type: "update" }) + .pipe(Effect.forkScoped); + const incoming = yield* Deferred.await(sent); + assert.isFalse( + (yield* command.request({ version: 1, requestId: "second", type: "update" })).ok, + ); + assert.isTrue( + yield* command.complete({ + version: 1, + requestId: incoming.requestId, + ok: true, + status: "cancelled", + }), + ); + assert.isFalse( + yield* command.complete({ + version: 1, + requestId: incoming.requestId, + ok: true, + status: "started", + }), + ); + assert.equal((yield* command.inspect).state, "ready"); + yield* Fiber.join(first); + }).pipe( + Effect.provide( + layer((request) => { + Deferred.doneUnsafe(sent, Effect.succeed(request)); + }), + ), + ); + }), +); +it.effect("releases a reservation if the renderer disappears during delivery", () => + Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + yield* command.setReady(true, "renderer"); + assert.isFalse((yield* command.request({ version: 1, requestId: "gone", type: "update" })).ok); + assert.equal((yield* command.inspect).state, "ready"); + }).pipe( + Effect.provide( + layer(() => { + throw new Error("Renderer closed"); + }), + ), + ), +); + +it.effect("clears an expired confirmation so a later request can proceed", () => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + yield* Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + yield* command.setReady(true, "renderer"); + const request = yield* command + .request({ version: 1, requestId: "expired", type: "update" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(sent); + yield* TestClock.adjust(Duration.minutes(5)); + assert.isFalse((yield* Fiber.join(request)).ok); + assert.isFalse( + yield* command.complete({ version: 1, requestId: "expired", ok: true, status: "started" }), + ); + const reservation = yield* command.reserve; + assert.isNotNull(reservation); + yield* command.finish(reservation!); + }).pipe( + Effect.provide( + layer(() => { + Deferred.doneUnsafe(sent, Effect.void); + }), + ), + ); + }), +); + +it.effect("an older request's cleanup cannot clear or release its successor", () => + Effect.gen(function* () { + const firstSent = yield* Deferred.make(); + const secondSent = yield* Deferred.make(); + let sent = 0; + yield* Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + yield* command.setReady(true, "renderer"); + const first = yield* command + .request({ version: 1, requestId: "first", type: "update" }) + .pipe(Effect.forkScoped); + const a = yield* Deferred.await(firstSent); + yield* command.cancel("first"); + const second = yield* command + .request({ version: 1, requestId: "second", type: "update" }) + .pipe(Effect.forkScoped); + const b = yield* Deferred.await(secondSent); + yield* Fiber.join(first); + yield* command.finish(a.requestId); + assert.equal((yield* command.inspect).state, "busy"); + assert.isTrue( + yield* command.complete({ + version: 1, + requestId: b.requestId, + ok: true, + status: "cancelled", + }), + ); + assert.deepInclude(yield* Fiber.join(second), { requestId: "second", status: "cancelled" }); + }).pipe( + Effect.provide( + layer((request) => { + Deferred.doneUnsafe(sent++ === 0 ? firstSent : secondSent, Effect.succeed(request)); + }), + ), + ); + }), +); + +it.effect("keeps a started run reserved when its renderer unregisters", () => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + yield* Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + yield* command.setReady(true, "renderer"); + const response = yield* command + .request({ version: 1, requestId: "running", type: "update" }) + .pipe(Effect.forkScoped); + const incoming = yield* Deferred.await(sent); + yield* command.complete({ + version: 1, + requestId: incoming.requestId, + ok: true, + status: "started", + }); + assert.deepInclude(yield* Fiber.join(response), { requestId: "running", status: "started" }); + yield* command.setReady(false, "renderer"); + yield* command.setReady(true, "replacement"); + assert.isNull(yield* command.reserve); + yield* command.finish(incoming.requestId); + assert.equal((yield* command.inspect).state, "ready"); + }).pipe( + Effect.provide( + layer((request) => { + Deferred.doneUnsafe(sent, Effect.succeed(request)); + }), + ), + ); + }), +); diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.ts b/apps/desktop/src/updates/DesktopUpdateCommand.ts new file mode 100644 index 000000000000..5827f0fb78f8 --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdateCommand.ts @@ -0,0 +1,195 @@ +import { + type DesktopUpdateCommandRequest, + type DesktopUpdateCommandResponse, + type T3UpdateCheck, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Duration from "effect/Duration"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { UPDATE_COMMAND_REQUEST_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; + +export class DesktopUpdateCommand extends Context.Service< + DesktopUpdateCommand, + { + readonly inspect: Effect.Effect; + readonly request: ( + request: DesktopUpdateCommandRequest, + ) => Effect.Effect; + readonly setReady: (ready: boolean, rendererId: string) => Effect.Effect; + readonly cancel: (callerId: string) => Effect.Effect; + readonly complete: (response: DesktopUpdateCommandResponse) => Effect.Effect; + readonly reserve: Effect.Effect; + readonly finish: (reservation: string) => Effect.Effect; + } +>()("@t3tools/desktop/updates/DesktopUpdateCommand") {} + +interface PendingCommand { + readonly requestId: string; + readonly callerId: string; + readonly reply: Deferred.Deferred; +} + +const make = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const windows = yield* ElectronWindow.ElectronWindow; + const ready = yield* Ref.make(null); + const lock = yield* Ref.make<{ readonly owner: string | null; readonly next: number }>({ + owner: null, + next: 0, + }); + const pending = yield* Ref.make>(Option.none()); + const finish = (reservation: string) => + Ref.update(lock, (current) => + current.owner === reservation ? { ...current, owner: null } : current, + ); + const clearPending = (reservation: string) => + Ref.update(pending, (current) => + Option.isSome(current) && current.value.requestId === reservation ? Option.none() : current, + ); + const failure = (requestId: string, message: string): DesktopUpdateCommandResponse => ({ + version: 1, + requestId, + ok: false, + code: "renderer-unavailable", + message, + }); + const complete = Effect.fn("DesktopUpdateCommand.complete")(function* ( + response: DesktopUpdateCommandResponse, + ) { + const current = yield* Ref.modify( + pending, + (value): readonly [Option.Option, Option.Option] => + Option.isSome(value) && value.value.requestId === response.requestId + ? ([value, Option.none()] as const) + : ([Option.none(), value] as const), + ); + if (Option.isNone(current)) return false; + if (!response.ok || !("status" in response) || response.status !== "started") + yield* finish(current.value.requestId); + yield* Deferred.succeed(current.value.reply, { + ...response, + requestId: current.value.callerId, + }); + return true; + }); + const reserve = Effect.gen(function* () { + if (yield* updates.isActionActive) return null; + return yield* Ref.modify(lock, (current) => { + if (current.owner !== null) return [null, current] as const; + const owner = `desktop-update-${current.next}`; + return [owner, { owner, next: current.next + 1 }] as const; + }); + }); + const inspect = Effect.gen(function* () { + const state = yield* updates.getState; + const main = yield* windows.main; + const available = + (yield* Ref.get(ready)) !== null && + Option.isSome(main) && + !main.value.webContents.isDestroyed(); + const occupied = (yield* Ref.get(lock)).owner !== null || (yield* updates.isActionActive); + const targetVersion = state.downloadedVersion ?? state.availableVersion; + return { + schema: "t3.update-check.v1", + owner: "desktop", + pathway: "desktop-coordinated", + coordinated: available, + currentVersion: state.currentVersion, + targetVersion, + state: !available + ? "unavailable" + : occupied + ? "busy" + : !state.enabled + ? "blocked" + : state.status === "error" + ? "error" + : "ready", + reason: !available + ? "Open or update T3 Code so its coordinated updater is available." + : "Cached desktop update state only; connected machines and providers have not been checked.", + } satisfies T3UpdateCheck; + }); + return DesktopUpdateCommand.of({ + inspect, + reserve, + finish, + complete, + cancel: Effect.fn("DesktopUpdateCommand.cancel")(function* (callerId) { + const current = yield* Ref.get(pending); + if (Option.isSome(current) && current.value.callerId === callerId) { + yield* complete(failure(current.value.requestId, "The update command disconnected.")); + } + }), + setReady: Effect.fn("DesktopUpdateCommand.setReady")(function* (value, rendererId) { + if (value) { + yield* Ref.set(ready, rendererId); + return; + } + if ((yield* Ref.get(ready)) !== rendererId) return; + yield* Ref.set(ready, null); + const current = yield* Ref.get(pending); + if (Option.isSome(current)) + yield* complete(failure(current.value.requestId, "The desktop update coordinator closed.")); + }), + request: Effect.fn("DesktopUpdateCommand.request")(function* (request) { + if (request.type === "update-check") + return { version: 1, requestId: request.requestId, ok: true, check: yield* inspect }; + if ((yield* Ref.get(ready)) === null) + return failure( + request.requestId, + "Open or update T3 Code, then run t3 update again. No standalone update was attempted.", + ); + const reservation = yield* reserve; + if (reservation === null) + return failure( + request.requestId, + "A T3 Code update or confirmation is already in progress.", + ); + const main = yield* windows.main; + if (Option.isNone(main) || main.value.webContents.isDestroyed()) { + yield* finish(reservation); + return failure(request.requestId, "The desktop update coordinator is unavailable."); + } + const reply = yield* Deferred.make(); + yield* Ref.set( + pending, + Option.some({ requestId: reservation, callerId: request.requestId, reply }), + ); + const delivered = yield* Effect.sync(() => { + main.value.webContents.send(UPDATE_COMMAND_REQUEST_CHANNEL, { + ...request, + requestId: reservation, + }); + return true; + }).pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!delivered) { + yield* clearPending(reservation); + yield* finish(reservation); + return failure(request.requestId, "The desktop update coordinator became unavailable."); + } + return yield* Deferred.await(reply).pipe( + Effect.timeoutOption(Duration.minutes(5)), + Effect.map( + Option.getOrElse(() => + failure(request.requestId, "The desktop update request timed out."), + ), + ), + Effect.tap((response) => + response.ok && "status" in response && response.status === "started" + ? Effect.void + : finish(reservation), + ), + Effect.ensuring(clearPending(reservation)), + Effect.onInterrupt(() => finish(reservation)), + ); + }), + }); +}); +export const layer = Layer.effect(DesktopUpdateCommand, make); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index c1bb89b759e6..e5634b669d87 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -1,3 +1,4 @@ +import * as DesktopUpdateCommand from "../updates/DesktopUpdateCommand.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -118,6 +119,17 @@ const configureMenu = ( Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)), Layer.provideMerge(layerDesktopWindow(selectedAction)), Layer.provideMerge(layerDesktopUpdates), + Layer.provide( + Layer.mock(DesktopUpdateCommand.DesktopUpdateCommand, { + request: (request) => + Effect.succeed({ + version: 1, + requestId: request.requestId, + ok: true, + status: "cancelled", + }), + }), + ), Layer.provideMerge(layerElectronDialog), Layer.provideMerge(layerElectronApp), Layer.provideMerge( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 07fd8654af6b..5d113ff74f73 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -1,3 +1,4 @@ +import * as DesktopUpdateCommand from "../updates/DesktopUpdateCommand.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -33,6 +34,7 @@ export class DesktopApplicationMenu extends Context.Service< >()("@t3tools/desktop/window/DesktopApplicationMenu") {} type DesktopApplicationMenuRuntimeServices = + | DesktopUpdateCommand.DesktopUpdateCommand | DesktopUpdates.DesktopUpdates | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; @@ -69,6 +71,14 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { const electronDialog = yield* ElectronDialog.ElectronDialog; const result = yield* updates.check("menu"); const updateState = result.state; + if (updateState.status === "available" || updateState.status === "downloaded") { + yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).request({ + version: 1, + requestId: "menu-update", + type: "update", + }); + return; + } if (updateState.status === "up-to-date") { yield* electronDialog.showMessageBox({ diff --git a/apps/server/src/binMain.ts b/apps/server/src/binMain.ts index 0dabd7387344..4f58445ad407 100644 --- a/apps/server/src/binMain.ts +++ b/apps/server/src/binMain.ts @@ -31,7 +31,7 @@ export async function runCliCommand() { const { runNativeSpeechWorker } = await import("./speech/native.ts"); await runNativeSpeechWorker(mode, process.argv[4]); } else { - enableCliCompileCache(); + if (!(command === "update" && process.argv.includes("--check"))) enableCliCompileCache(); // Windows desktop launches without stdin; attach before reading, except on fast paths using pipes. const { attachConsoleStdin } = await import("./cli/consoleStdin.ts"); attachConsoleStdin(); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index eecdd7fbe501..cd3a2c629928 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -9,7 +9,11 @@ import * as NodePath from "node:path"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import type { DesktopAppActivationRequest } from "@t3tools/contracts"; +import type { + DesktopAppActivationRequest, + DesktopAppControlRequest, + DesktopUpdateCommandRequest, +} from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; @@ -17,6 +21,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Exit from "effect/Exit"; import { Command } from "effect/cli"; import { describe, expect } from "vite-plus/test"; @@ -71,12 +76,14 @@ const pathExists = (path: string) => ), ); -async function startFakeDesktop(input: { +async function startFakeDesktop< + Request extends DesktopAppControlRequest = DesktopAppActivationRequest, +>(input: { readonly baseDir: string; readonly stateSubdirectory?: "userdata" | "dev"; readonly platform: NodeJS.Platform; readonly userId: number | undefined; - readonly reply?: (request: DesktopAppActivationRequest) => unknown; + readonly reply?: (request: Request) => unknown; }) { const target = resolveDesktopAppControlAddress({ stateDir: NodePath.join(input.baseDir, input.stateSubdirectory ?? "userdata"), @@ -92,7 +99,7 @@ async function startFakeDesktop(input: { }); } - const received: DesktopAppActivationRequest[] = []; + const received: Request[] = []; const server = NodeNet.createServer((socket) => { socket.setEncoding("utf8"); let buffer = ""; @@ -100,7 +107,7 @@ async function startFakeDesktop(input: { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline === -1) return; - const request = JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationRequest; + const request = JSON.parse(buffer.slice(0, newline)) as Request; received.push(request); const response = input.reply ? input.reply(request) @@ -391,3 +398,62 @@ describe("t3 app", () => { ), ); }); + +it.effect("routes a legacy desktop shim's update and read-only check through desktop control", () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; + const root = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-update-")), + ); + const restore = setProcessEnv("T3CODE_CLI_PATH", NodePath.join(root, "bin/t3")); + const desktop = yield* Effect.promise(() => + startFakeDesktop({ + baseDir: root, + platform, + userId, + reply: (request) => ({ + version: 1, + requestId: request.requestId, + ok: true, + ...(request.type === "update-check" + ? { + check: { + schema: "t3.update-check.v1", + coordinated: true, + owner: "desktop", + pathway: "desktop-coordinated", + currentVersion: "0.1.7", + targetVersion: "0.1.8", + state: "ready", + }, + } + : { status: "cancelled" }), + }), + }), + ); + try { + yield* runCli(["update", "--check", "--json", "--base-dir", root]); + assert.equal(desktop.received[0]?.type, "update-check"); + assert.deepStrictEqual(yield* Effect.promise(() => NodeFSP.readdir(root)), []); + yield* runCli(["update", "--base-dir", root]); + assert.equal(desktop.received[1]?.type, "update"); + yield* runCli(["service", "update", "--base-dir", root]); + assert.equal(desktop.received[2]?.type, "update"); + for (const options of [ + ["--yes"], + ["--channel", "stable"], + ["--allow-downgrade"], + ["0.1.8"], + ]) { + const refused = yield* runCli(["update", ...options, "--base-dir", root]).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(refused)); + } + assert.equal(desktop.received.length, 3); + } finally { + restore(); + yield* Effect.promise(() => desktop.close()); + yield* Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })); + } + }), +); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index e4182945215c..3b273761462b 100644 --- a/apps/server/src/cli/app.ts +++ b/apps/server/src/cli/app.ts @@ -8,6 +8,9 @@ import { DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, DesktopAppActivationErrorCode, DesktopAppActivationResponse, + DesktopUpdateCommandResponse, + type DesktopAppControlRequest, + type DesktopAppControlResponse, type DesktopAppActivationPlatform, type DesktopAppActivationRequest, } from "@t3tools/contracts"; @@ -31,6 +34,7 @@ import { baseDirFlag } from "./config.ts"; const CLI_RESPONSE_TIMEOUT_MS = 17_000; const MAX_RESPONSE_BYTES = 64 * 1024; const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse); +const isDesktopUpdateCommandResponse = Schema.is(DesktopUpdateCommandResponse); export class DesktopAppSshUnsupportedError extends Schema.TaggedError()( "DesktopAppSshUnsupportedError", @@ -82,13 +86,13 @@ function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppAct return platform === "darwin" || platform === "linux" || platform === "win32"; } -async function sendDesktopAppActivationRequest(input: { +export async function sendDesktopAppControlRequest(input: { readonly address: DesktopAppControlAddress; readonly fallbackAddress?: DesktopAppControlAddress; readonly userId: number | undefined; - readonly request: DesktopAppActivationRequest; + readonly request: DesktopAppControlRequest; readonly timeoutMs?: number; -}): Promise { +}): Promise { if (input.address.directory !== null) { const stat = await NodeFSP.lstat(input.address.directory); if ( @@ -109,7 +113,7 @@ async function sendDesktopAppActivationRequest(input: { const finish = ( result: - | { readonly type: "success"; readonly response: DesktopAppActivationResponse } + | { readonly type: "success"; readonly response: DesktopAppControlResponse } | { readonly type: "failure"; readonly error: Error }, ) => { if (settled) return; @@ -150,7 +154,11 @@ async function sendDesktopAppActivationRequest(input: { }); return; } - if (!isDesktopAppActivationResponse(parsed)) { + if ( + !(input.request.type === "open-workspace" + ? isDesktopAppActivationResponse(parsed) + : isDesktopUpdateCommandResponse(parsed)) + ) { finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); return; } @@ -174,7 +182,7 @@ async function sendDesktopAppActivationRequest(input: { clearTimeout(timeout); socket.destroy(); resolve( - sendDesktopAppActivationRequest({ + sendDesktopAppControlRequest({ address: input.fallbackAddress, userId: input.userId, request: input.request, @@ -240,7 +248,7 @@ const runAppCommand = Effect.fn("cli.app")(function* (flags: { const response = yield* Effect.tryPromise({ try: () => - sendDesktopAppActivationRequest({ + sendDesktopAppControlRequest({ address, userId, ...(fallbackAddress === undefined ? {} : { fallbackAddress }), diff --git a/apps/server/src/cli/desktopUpdate.ts b/apps/server/src/cli/desktopUpdate.ts new file mode 100644 index 000000000000..07c36c2b59c5 --- /dev/null +++ b/apps/server/src/cli/desktopUpdate.ts @@ -0,0 +1,72 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This CLI adapter addresses the desktop app's private local control socket. +import * as NodeOS from "node:os"; +import type { T3UpdateCheck } from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Schema from "effect/Schema"; +import { sendDesktopAppControlRequest } from "./app.ts"; + +export class DesktopCliUpdateError extends Schema.TaggedError()( + "DesktopCliUpdateError", + { reason: Schema.String }, +) { + override get message() { + return this.reason; + } +} +export const requestDesktopUpdate = Effect.fn("cli.update.desktop")(function* (input: { + readonly baseDir: string; + readonly currentVersion: string; + readonly check: boolean; +}) { + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; + const check: T3UpdateCheck = { + schema: "t3.update-check.v1", + owner: "desktop", + pathway: "desktop-coordinated", + coordinated: false, + currentVersion: input.currentVersion, + targetVersion: null, + state: "unavailable", + reason: "Open or update T3 Code, then run t3 update again. No standalone update was attempted.", + }; + const response = yield* Effect.tryPromise({ + try: () => + sendDesktopAppControlRequest({ + address: resolveDesktopAppControlAddress({ + stateDir: path.join(input.baseDir, "userdata"), + platform, + userId, + tempDir: NodeOS.tmpdir(), + joinPath: path.join, + }), + userId, + request: { + version: 1, + requestId: input.check ? "update-check" : SecureRandom.uuidV4Unsafe(), + type: input.check ? "update-check" : "update", + }, + timeoutMs: input.check ? 5_000 : 5 * 60_000, + }), + catch: () => new DesktopCliUpdateError({ reason: check.reason! }), + }).pipe(Effect.option); + if (response._tag === "None") { + if (input.check) return check; + return yield* new DesktopCliUpdateError({ reason: check.reason! }); + } + const result = response.value; + if (!result.ok) { + if (input.check) return { ...check, reason: result.message }; + return yield* new DesktopCliUpdateError({ reason: result.message }); + } + if ("check" in result) return result.check; + if ("status" in result) return result.status; + return yield* new DesktopCliUpdateError({ + reason: "This desktop app cannot coordinate t3 update. Update the desktop app first.", + }); +}); diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 900c41e66174..903f2e13f9f7 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -60,7 +60,7 @@ function makeTestService( it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { it.effect.each( - (["install", "update"] as const).flatMap((command) => [ + (["install"] as const).flatMap((command) => [ { command, label: "a downgrade", @@ -178,41 +178,39 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands ); // regression: #13239 a stale nvm-installed CLI reported "already using" its own version - it.effect.each(["install", "update"] as const)( - "%s names the CLI that found nothing to change", - (command) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); - const { service, installOptions } = makeTestService(status); - vi.spyOn(BootService, "layer").mockReturnValue( - Layer.succeed(BootService.BootService, service), - ); - const node = "/home/me/.nvm/versions/node/v26.8.2/bin/node"; - const script = "/home/me/.nvm/versions/node/v26.8.2/lib/node_modules/t3/dist/bin.mjs"; + it.effect.each(["install"] as const)("%s names the CLI that found nothing to change", (command) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions } = makeTestService(status); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + const node = "/home/me/.nvm/versions/node/v26.8.2/bin/node"; + const script = "/home/me/.nvm/versions/node/v26.8.2/lib/node_modules/t3/dist/bin.mjs"; - const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ - command, - "--base-dir", - baseDir, - ]).pipe( - Effect.andThen(TestConsole.logLines), - Effect.map((lines) => lines.join("\n")), - Effect.provideService(HostProcess.Environment, {}), - Effect.provideService(HostProcess.IsExecutable, false), - Effect.provideService(HostProcess.ExecutablePath, node), - Effect.provideService(HostProcess.Arguments, [node, script, "service", command]), - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), - Layer.fresh(TestConsole.layer), - ), + const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + command, + "--base-dir", + baseDir, + ]).pipe( + Effect.andThen(TestConsole.logLines), + Effect.map((lines) => lines.join("\n")), + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, node), + Effect.provideService(HostProcess.Arguments, [node, script, "service", command]), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), ), - ); - expect(output).toContain(`t3@${packageJson.version}`); - expect(output).toContain(script); - expect(installOptions).toEqual([]); - }), + ), + ); + expect(output).toContain(`t3@${packageJson.version}`); + expect(output).toContain(script); + expect(installOptions).toEqual([]); + }), ); it.effect.each([ diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index ce43154116b8..d9d91878a034 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -1,3 +1,4 @@ +import { runUpdateCommand } from "./update.ts"; import * as NodeUtil from "node:util"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -5,6 +6,7 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient } from "effect/http"; @@ -239,24 +241,20 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ); const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( - Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."), + Command.withDescription("Deprecated alias for t3 update."), Command.unlisted, Command.withHandler((flags) => - runServiceCommand( - flags, - Effect.gen(function* () { - yield* Console.log( - "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.", - ); - const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); - if (!result.changed) { - yield* Console.log( - `T3 Code service is already using t3@${packageJson.version} (${yield* resolveEntryPath}).`, - ); - return; - } - yield* Console.log(formatServiceChange(result)); - }), + Console.log("`t3 service update` is deprecated; using `t3 update`.").pipe( + Effect.andThen( + runUpdateCommand({ + ...flags, + channel: Option.none(), + version: Option.none(), + check: false, + json: false, + yes: false, + }), + ), ), ), ); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 99bf380f1c0d..cd3961fa02da 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -20,6 +20,8 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { requestDesktopUpdate, DesktopCliUpdateError } from "./desktopUpdate.ts"; +import { resolveBaseDir } from "../os-jank.ts"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { @@ -243,6 +245,14 @@ export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(functio }); const updateFlags = { + check: Flag.Boolean("check").pipe( + Flag.withDescription("Inspect cached update capability without changing the installation."), + Flag.withDefault(false), + ), + json: Flag.Boolean("json").pipe( + Flag.withDescription("Print the versioned --check result as JSON."), + Flag.withDefault(false), + ), ...projectLocationFlags, channel: Flag.Literals("channel", CLI_RELEASE_CHANNELS).pipe( Flag.withDescription( @@ -270,37 +280,95 @@ const versionArgument = Argument.String("version").pipe( Argument.optional, ); +export const runUpdateCommand = Effect.fn("cli.update.command")(function* (flags: { + readonly baseDir: Option.Option; + readonly check: boolean; + readonly json: boolean; + readonly channel: Option.Option; + readonly version: Option.Option; + readonly allowDowngrade: boolean; + readonly yes: boolean; +}) { + const environment = yield* HostProcess.Environment; + const desktopOwned = + environment.T3CODE_DESKTOP_CLI === "1" || Boolean(environment.T3CODE_CLI_PATH?.trim()); + if (flags.json && !flags.check) + return yield* new DesktopCliUpdateError({ reason: "--json requires --check." }); + if (desktopOwned) { + if ( + Option.isSome(flags.version) || + Option.isSome(flags.channel) || + flags.allowDowngrade || + flags.yes + ) { + return yield* new DesktopCliUpdateError({ + reason: + "Desktop updates use the app's coordinated selection and confirmation. Run t3 update without version, --channel, --allow-downgrade, or --yes; switch tracks in the app.", + }); + } + const result = yield* requestDesktopUpdate({ + baseDir: yield* resolveBaseDir( + Option.getOrUndefined(flags.baseDir) ?? environment.T3CODE_HOME, + ), + currentVersion: packageJson.version, + check: flags.check, + }); + return yield* Console.log( + typeof result === "string" + ? result === "started" + ? "The desktop coordinated update started. Follow its progress in T3 Code." + : result === "cancelled" + ? "Update cancelled. No update was started." + : "No updates are currently offered by this desktop updater." + : flags.json + ? JSON.stringify(result) + : `${result.pathway}: ${result.state}. ${result.reason ?? ""}`, + ); + } + if (flags.check) { + const result = { + schema: "t3.update-check.v1", + coordinated: false, + owner: "standalone", + pathway: "standalone", + currentVersion: packageJson.version, + targetVersion: null, + state: "unavailable", + reason: + "This standalone CLI updates its own runtime and service; it does not coordinate a desktop app.", + }; + return yield* Console.log(flags.json ? JSON.stringify(result) : result.reason); + } + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* runUpdate({ + baseDir: config.baseDir, + logsDir: config.logsDir, + serverRuntimeStatePath: config.serverRuntimeStatePath, + channel: Option.getOrUndefined(flags.channel), + requestedVersion: Option.getOrUndefined(flags.version), + allowDowngrade: flags.allowDowngrade, + assumeYes: flags.yes, + }).pipe( + Effect.provide( + Layer.mergeAll( + CliService.layer(config), + ProcessRunner.layer, + FetchHttpClient.layer, + ManagedPolicy.layer, + ), + ), + ); +}); + export const updateCommand = Command.make("update", { ...updateFlags, version: versionArgument, }).pipe( Command.withDescription( - "Download a newer t3 and switch this machine to it, including the background service when one is installed.", - ), - Command.withHandler((flags) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - return yield* runUpdate({ - baseDir: config.baseDir, - logsDir: config.logsDir, - serverRuntimeStatePath: config.serverRuntimeStatePath, - channel: Option.getOrUndefined(flags.channel), - requestedVersion: Option.getOrUndefined(flags.version), - allowDowngrade: flags.allowDowngrade, - assumeYes: flags.yes, - }).pipe( - Effect.provide( - Layer.mergeAll( - CliService.layer(config), - ProcessRunner.layer, - FetchHttpClient.layer, - ManagedPolicy.layer, - ), - ), - ); - }), + "Update this desktop installation through its coordinated updater, or update a standalone CLI and service.", ), + Command.withHandler(runUpdateCommand), ); const findForegroundServer = Effect.fn("cli.update.find_foreground_server")(function* (input: { diff --git a/apps/server/src/cliCompileCache.test.ts b/apps/server/src/cliCompileCache.test.ts index b58e7969999d..4b4d80f5b5a0 100644 --- a/apps/server/src/cliCompileCache.test.ts +++ b/apps/server/src/cliCompileCache.test.ts @@ -61,6 +61,15 @@ it.live("loads the CLI with its cache settings only when the command needs it", expect(normal.stderr.replaceAll("\\", "/")).toContain( `CACHE=${root}/cache/t3code/compile-cache`.replaceAll("\\", "/"), ); + const check = yield* run({}, ["update", "--check", "--json"]); + expect(check.exitCode).toBe(0); + expect(check.stderr).toContain("CACHE=disabled"); + expect(JSON.parse(check.stdout)).toMatchObject({ + schema: "t3.update-check.v1", + owner: "standalone", + coordinated: false, + }); + expect(yield* fs.exists(`${root}/home`)).toBe(false); const launcher = yield* run({ T3CODE_HOME: "" }, ["__service-launcher"]); expect(launcher.exitCode).toBe(1); expect(launcher.stderr).toContain("[service-launcher] T3CODE_HOME is required"); diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx new file mode 100644 index 000000000000..7d62975a9c41 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx @@ -0,0 +1,83 @@ +import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { act, render, stubGlobal } from "../../../test/render"; +import "../../index.css"; +import { DesktopUpdateCoordinator } from "./DesktopUpdateCoordinator"; + +const mocks = vi.hoisted(() => ({ run: vi.fn(async () => undefined) })); +vi.mock("../sidebar/useUpdateEverything", () => ({ + useUpdateEverything: () => ({ + hasUpdates: true, + version: "0.1.8", + providerCount: 0, + rows: [ + { environmentId: null, label: "This app", app: "this-app", skipped: null, providers: [] }, + ], + restarts: [], + run: mocks.run, + }), +})); + +it("uses one native confirmation and cancels without starting the coordinated runner", async () => { + mocks.run.mockClear(); + let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; + const complete = vi.fn(async () => true); + const finish = vi.fn(async () => undefined); + const state: DesktopUpdateState = { + enabled: true, + status: "available", + channel: "latest", + currentVersion: "0.1.7", + availableVersion: "0.1.8", + downloadedVersion: null, + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; + using _bridge = stubGlobal("desktopBridge", { + getUpdateState: async () => state, + updateCommand: { + setReady: async () => undefined, + complete, + finish, + onRequest: (callback: typeof listener) => { + listener = callback; + return () => { + listener = undefined; + }; + }, + }, + }); + await using screen = await render(); + await act(async () => listener?.({ version: 1, requestId: "cli", type: "update" })); + await expect.element(page.getByRole("alertdialog", { name: "Update 1 machine?" })).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Cancel", exact: true })); + await vi.waitFor(() => + expect(complete).toHaveBeenCalledWith({ + version: 1, + requestId: "cli", + ok: true, + status: "cancelled", + }), + ); + expect(mocks.run).not.toHaveBeenCalled(); + await act(async () => listener?.({ version: 1, requestId: "about", type: "update" })); + await expect.element(page.getByRole("alertdialog", { name: "Update 1 machine?" })).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Update", exact: true })); + await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledOnce()); + expect(complete).toHaveBeenCalledWith({ + version: 1, + requestId: "about", + ok: true, + status: "started", + }); + await vi.waitFor(() => expect(finish).toHaveBeenCalledOnce()); +}); diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx new file mode 100644 index 000000000000..5e67b80470a0 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -0,0 +1,162 @@ +import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useState } from "react"; +import { downloadLocalUpdate, installLocalUpdate } from "../sidebar/SidebarUpdatePill"; +import { useUpdateEverything } from "../sidebar/useUpdateEverything"; +import { UpdateEverythingDialog } from "../sidebar/UpdateEverythingDialog"; +import { + getDesktopUpdateDownloadedVersion, + resolveDesktopUpdateButtonAction, +} from "../desktopUpdate.logic"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +export function DesktopUpdateCoordinator() { + const command = window.desktopBridge?.updateCommand; + const [rendererId] = useState(() => crypto.randomUUID()); + const [request, setRequest] = useState(null); + const [prepared, setPrepared] = useState(null); + const action = prepared?.enabled ? resolveDesktopUpdateButtonAction(prepared) : "none"; + const localVersion = + prepared && action !== "none" ? getDesktopUpdateDownloadedVersion(prepared) : null; + const everything = useUpdateEverything(localVersion); + + const receive = useEffectEvent(async (incoming: DesktopUpdateCommandRequest) => { + if (!command) return; + const bridge = window.desktopBridge!; + if (request !== null) { + await command.complete({ + version: 1, + requestId: incoming.requestId, + ok: false, + code: "internal-error", + message: "An update is already in progress.", + }); + return; + } + let current = await bridge.getUpdateState(); + if ( + current.status === "idle" || + current.status === "up-to-date" || + current.status === "error" + ) { + current = (await bridge.checkForUpdate()).state; + } + if (current.status === "checking" || current.status === "downloading") { + await command.complete({ + version: 1, + requestId: incoming.requestId, + ok: false, + code: "internal-error", + message: "An update check or download is already in progress.", + }); + return; + } + const localAction = current.enabled ? resolveDesktopUpdateButtonAction(current) : "none"; + if (localAction === "none" && !everything.hasUpdates) { + const reason = !current.enabled + ? (current.message ?? "Desktop updates are disabled.") + : current.status === "error" + ? current.message + : null; + await command.complete( + reason + ? { + version: 1, + requestId: incoming.requestId, + ok: false, + code: "internal-error", + message: reason, + } + : { version: 1, requestId: incoming.requestId, ok: true, status: "up-to-date" }, + ); + return; + } + setPrepared(current); + setRequest(incoming); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- receives native update intents independently of the local backend connection + useEffect(() => { + if (!command) return; + const unsubscribe = command.onRequest((incoming) => { + void receive(incoming).catch(async () => { + await command.complete({ + version: 1, + requestId: incoming.requestId, + ok: false, + code: "internal-error", + message: "The coordinated update could not be prepared.", + }); + }); + }); + void command.setReady(true, rendererId); + return () => { + unsubscribe(); + void command.setReady(false, rendererId); + }; + }, [command, rendererId]); + + const cancel = async () => { + if (!request || !command) return; + setRequest(null); + await command.complete({ + version: 1, + requestId: request.requestId, + ok: true, + status: "cancelled", + }); + }; + const confirm = async () => { + if (!request || !command) return; + const currentRequest = request; + setRequest(null); + if ( + !(await command.complete({ + version: 1, + requestId: currentRequest.requestId, + ok: true, + status: "started", + })) + ) + return; + try { + const bridge = window.desktopBridge!; + await everything.run({ + ...(action === "download" + ? { + downloadLocal: (reportError) => + downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + } + : {}), + ...(action !== "none" + ? { + installLocal: (reportError) => + installLocalUpdate(bridge, reportError, localVersion ?? undefined), + } + : {}), + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update T3 Code", + description: error instanceof Error ? error.message : "The coordinated update failed.", + }), + ); + } finally { + await command.finish(currentRequest.requestId); + } + }; + if (!request) return null; + return ( + { + if (!open) void cancel(); + }} + version={everything.version} + providerCount={everything.providerCount} + rows={everything.rows} + restarts={everything.restarts} + onConfirm={() => void confirm()} + /> + ); +} diff --git a/apps/web/src/components/desktop/desktopUpdateRequest.ts b/apps/web/src/components/desktop/desktopUpdateRequest.ts new file mode 100644 index 000000000000..3e3bce9b8384 --- /dev/null +++ b/apps/web/src/components/desktop/desktopUpdateRequest.ts @@ -0,0 +1,19 @@ +import { stackedThreadToast, toastManager } from "../ui/toast"; + +export async function requestCoordinatedDesktopUpdate(): Promise { + try { + const command = window.desktopBridge?.updateCommand; + if (!command) throw new Error("Update T3 Code to use its coordinated updater."); + const response = await command.request(); + if (!response.ok) throw new Error(response.message); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update T3 Code", + description: + error instanceof Error ? error.message : "The coordinated updater is unavailable.", + }), + ); + } +} diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 51b2b92b0a8b..2851165d19c1 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -120,13 +120,6 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string return "Up to date"; } -export function getDesktopUpdateInstallConfirmationMessage( - state: Pick, -): string { - const version = state.downloadedVersion ?? state.availableVersion; - return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; -} - export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { if (!result.accepted || result.completed) return null; if (typeof result.state.message !== "string") return null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c53ca8a2dc71..55a2b00ace0e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,4 @@ +import { requestCoordinatedDesktopUpdate } from "../desktop/desktopUpdateRequest"; import { useAtomValue } from "@effect/atom-react"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; @@ -36,7 +37,6 @@ import { canCheckForUpdate, getDesktopUpdateButtonTooltip, getDesktopUpdateDisabledReason, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; @@ -193,56 +193,11 @@ function AboutVersionSection() { const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; - if (action === "download") { - void bridge.downloadUpdate().catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: error instanceof Error ? error.message : "Download failed.", - }), - ); - }); - return; - } - - if (action === "install") { + if (action === "download" || action === "install") { if (isUpdateActionPending) return; setIsUpdateActionPending(true); - let confirmed = false; - try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage( - updateState ?? { availableVersion: null, downloadedVersion: null }, - ), - ); - } catch (error) { - setIsUpdateActionPending(false); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not confirm update", - description: error instanceof Error ? error.message : "Update confirmation failed.", - }), - ); - return; - } - if (!confirmed) { - setIsUpdateActionPending(false); - return; - } - void bridge - .installUpdate() - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "Install failed.", - }), - ); - }) - .finally(() => setIsUpdateActionPending(false)); + await requestCoordinatedDesktopUpdate(); + setIsUpdateActionPending(false); return; } diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index f5db42c452c1..121555ba73d1 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -96,55 +96,70 @@ function SwitchPlan({ const run = async () => { const bridge = window.desktopBridge; if (!bridge) return; - // Stay open while the feed is checked; the run's own progress takes over after. - setChecking(true); - const fail = (description: string) => + const reservation = await bridge.updateCommand?.reserve(); + if (reservation == null) { toastManager.add( - stackedThreadToast({ type: "error", title: `Could not switch to ${label}`, description }), + stackedThreadToast({ + type: "error", + title: "Could not switch tracks", + description: "Another update is in progress, or the coordinated updater is unavailable.", + }), ); - const prepared = await prepareLocalTrackSwitch(bridge, { - channel: release.channel, - version: release.version, - reinstall: switchesLocal, - }); - onClose(); - if (!prepared.ok) { - fail(prepared.message); return; } - const { local } = prepared; - const result = await settlePromise(() => - everything.run( - local === null - ? {} - : { - ...(local.status === "available" - ? { - downloadLocal: (reportError) => - downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + try { + // Stay open while the feed is checked; the run's own progress takes over after. + setChecking(true); + const fail = (description: string) => + toastManager.add( + stackedThreadToast({ type: "error", title: `Could not switch to ${label}`, description }), + ); + const prepared = await prepareLocalTrackSwitch(bridge, { + channel: release.channel, + version: release.version, + reinstall: switchesLocal, + }); + onClose(); + if (!prepared.ok) { + fail(prepared.message); + return; + } + const { local } = prepared; + const result = await settlePromise(() => + everything.run( + local === null + ? {} + : { + ...(local.status === "available" + ? { + downloadLocal: (reportError) => + downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + } + : {}), + // Install only a build the database check covers, pinned so nothing replaces it. + installLocal: async (reportError) => { + const { downloadedVersion } = await bridge.getUpdateState(); + if ( + downloadedVersion === null || + !acceptsTrackSwitchRelease(downloadedVersion, release.channel, release.version) + ) { + reportError( + "Could not install update", + `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, + ); + return false; } - : {}), - // Install only a build the database check covers, pinned so nothing replaces it. - installLocal: async (reportError) => { - const { downloadedVersion } = await bridge.getUpdateState(); - if ( - downloadedVersion === null || - !acceptsTrackSwitchRelease(downloadedVersion, release.channel, release.version) - ) { - reportError( - "Could not install update", - `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, - ); - return false; - } - return installLocalUpdate(bridge, reportError, downloadedVersion); + return installLocalUpdate(bridge, reportError, downloadedVersion); + }, }, - }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - fail(error instanceof Error ? error.message : "An unexpected error occurred."); + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + fail(error instanceof Error ? error.message : "An unexpected error occurred."); + } + } finally { + await bridge.updateCommand.finish(reservation); } }; diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 03bb2c72849e..1798d2bed927 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -1,10 +1,11 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { act, render } from "../../../test/render"; +import { act, render, stubGlobal } from "../../../test/render"; import { SidebarUpdatePill } from "./SidebarUpdatePill"; +import { DesktopUpdateCoordinator } from "../desktop/DesktopUpdateCoordinator"; const mocks = vi.hoisted(() => { const listeners = new Set<() => void>(); @@ -81,6 +82,34 @@ function publish(update: DesktopUpdateState | null) { }); } +function coordinateUpdates() { + let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; + return stubGlobal("desktopBridge", { + getUpdateState: async () => ({ + ...nightlyUpdate([]), + status: "up-to-date", + availableVersion: null, + }), + checkForUpdate: async () => ({ + state: { ...nightlyUpdate([]), status: "up-to-date", availableVersion: null }, + }), + updateCommand: { + setReady: async () => undefined, + complete: async () => true, + request: async () => { + listener?.({ version: 1, requestId: "sidebar", type: "update" }); + return { version: 1, requestId: "sidebar", ok: true, status: "cancelled" }; + }, + onRequest: (callback: typeof listener) => { + listener = callback; + return () => { + listener = undefined; + }; + }, + }, + }); +} + const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]'); describe("SidebarUpdatePill release notes", () => { @@ -119,7 +148,13 @@ describe("SidebarUpdatePill release notes", () => { describe("SidebarUpdatePill update everything", () => { it("does not refocus the update button after a pointer cancel", async () => { setup(null, { hasUpdates: true }); - await using screen = await render(); + using _bridge = coordinateUpdates(); + await using screen = await render( + <> + + + , + ); const button = screen.getByRole("button", { name: "Update everything" }); await userEvent.click(button); const cancel = page.getByRole("button", { name: "Cancel" }); @@ -132,7 +167,13 @@ describe("SidebarUpdatePill update everything", () => { it("returns focus to the update button after Escape", async () => { setup(null, { hasUpdates: true }); - await using screen = await render(); + using _bridge = coordinateUpdates(); + await using screen = await render( + <> + + + , + ); const button = screen.getByRole("button", { name: "Update everything" }); await userEvent.click(button); await expect.element(page.getByRole("button", { name: "Cancel" })).toBeVisible(); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 6871b9149fd4..2f12d36dacb0 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,3 +1,4 @@ +import { requestCoordinatedDesktopUpdate } from "../desktop/desktopUpdateRequest"; import { type DesktopBridge, type DesktopUpdateState, @@ -10,7 +11,6 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; -import { ensureLocalApi } from "../../localApi"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { usePrimaryManagedPolicy } from "../../state/managedPolicy"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -21,7 +21,6 @@ import { getDesktopUpdateButtonTooltip, getDesktopUpdateDownloadedVersion, getDesktopUpdateIdleTooltip, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -42,11 +41,7 @@ import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; import { SidebarUpdateCapsule } from "./SidebarUpdateCapsule"; import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; import type { UpdateEverythingRun, UpdateRunLocalState } from "./updateEverything.logic"; -import { - clearSavedUpdateEverythingResult, - saveUpdateEverythingResult, - useUpdateEverythingRun, -} from "./updateEverythingRun"; +import { useUpdateEverythingRun } from "./updateEverythingRun"; import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything"; type SidebarUpdatePopoverChangeDetails = Parameters< @@ -281,46 +276,17 @@ function SidebarUpdateControl() { const bridge = window.desktopBridge; if (isInteractionDisabled) return; + if (isElectron && (runsEverything || action === "download" || action === "install")) { + await requestCoordinatedDesktopUpdate(); + return; + } if (runsEverything) { setIsConfirmingEverything(true); return; } - if (!bridge || !state) return; setIsActionPending(true); - if (action === "download") { - void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() => - setIsActionPending(false), - ); - return; - } - - if (action === "install") { - let confirmed = false; - try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage(state), - ); - } catch (error) { - setIsActionPending(false); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not confirm update", - description: error instanceof Error ? error.message : "Update confirmation failed.", - }), - ); - return; - } - if (!confirmed) { - setIsActionPending(false); - return; - } - void installLocalUpdate(bridge).finally(() => setIsActionPending(false)); - return; - } - if (!prefersReducedMotion) { setIsCheckAnimationLatched(true); setCheckAnimationKey((key) => key + 1); @@ -429,18 +395,7 @@ function SidebarUpdateControl() { icon={statusIcon} {...(run.localVersion === null && action === "install" && window.desktopBridge ? { - onRestart: async () => { - saveUpdateEverythingResult({ - localVersion: localUpdateVersion, - updatedServers: 0, - updatedProviders: 0, - failures: [], - restarting: [], - }); - if (!(await installLocalUpdate(window.desktopBridge!))) { - clearSavedUpdateEverythingResult(); - } - }, + onRestart: () => requestCoordinatedDesktopUpdate(), } : {})} /> diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1207b5017100..4ee7192976c3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { DesktopUpdateCoordinator } from "../components/desktop/DesktopUpdateCoordinator"; import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { @@ -193,6 +194,7 @@ function RootRouteBranch() { return ( <> + {isElectron ? : null} Promise; uninstall: () => Promise; }; + updateCommand: { + setReady: (ready: boolean, rendererId: string) => Promise; + onRequest: (listener: (request: DesktopUpdateCommandRequest) => void) => () => void; + complete: (response: DesktopUpdateCommandResponse) => Promise; + request: () => Promise; + reserve: () => Promise; + finish: (reservation: string) => Promise; + }; webLinks: { setReady: (ready: boolean) => Promise; onOpen: (listener: (url: string) => void) => () => void; From ff55e2f770dabaaa4cf914852bc75ae04cee5dfb Mon Sep 17 00:00:00 2001 From: trahloc Date: Sat, 7 Nov 2026 11:06:32 +0100 Subject: [PATCH 02/14] fix: preserve coordinated update retries and feedback --- .../src/window/DesktopApplicationMenu.test.ts | 86 +++++++++++++- .../src/window/DesktopApplicationMenu.ts | 11 +- apps/server/src/binMain.ts | 3 +- apps/server/src/cli/app.test.ts | 58 ++++++--- apps/server/src/cli/app.ts | 13 +- apps/server/src/cli/desktopUpdate.ts | 8 +- apps/server/src/cli/update.ts | 8 +- apps/server/src/cliCompileCache.test.ts | 4 + .../DesktopUpdateCoordinator.browser.test.tsx | 93 ++++++++++++--- .../desktop/DesktopUpdateCoordinator.tsx | 54 ++++----- .../settings/UpdateTrackSwitchDialog.tsx | 112 ++++++++++-------- 11 files changed, 315 insertions(+), 135 deletions(-) diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index e5634b669d87..2d10a11158fb 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -52,14 +52,14 @@ const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, { on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); -const layerElectronDialog = Layer.succeed(ElectronDialog.ElectronDialog, { +const electronDialog = ElectronDialog.ElectronDialog.of({ pickFolder: () => Effect.succeedNone, pickFiles: () => Effect.succeed([]), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, -} satisfies ElectronDialog.ElectronDialog["Service"]); +}); -const layerDesktopUpdates = Layer.succeed(DesktopUpdates.DesktopUpdates, { +const desktopUpdates = DesktopUpdates.DesktopUpdates.of({ getState: Effect.die("unexpected getState"), isActionActive: Effect.succeed(false), isInstallActive: Effect.succeed(false), @@ -72,7 +72,7 @@ const layerDesktopUpdates = Layer.succeed(DesktopUpdates.DesktopUpdates, { download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), installPrepared: () => Effect.die("unexpected installPrepared"), -} satisfies DesktopUpdates.DesktopUpdates["Service"]); +}); const layerDesktopWindow = (selectedAction: Deferred.Deferred) => Layer.succeed(DesktopWindow.DesktopWindow, { @@ -109,6 +109,11 @@ const configureMenu = ( selectedAction: Deferred.Deferred, applicationMenuTemplate: Deferred.Deferred, environment: Partial = {}, + overrides: { + readonly updates?: Partial; + readonly dialog?: Partial; + readonly command?: Partial; + } = {}, ) => Effect.gen(function* () { const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -118,7 +123,9 @@ const configureMenu = ( DesktopApplicationMenu.layer.pipe( Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)), Layer.provideMerge(layerDesktopWindow(selectedAction)), - Layer.provideMerge(layerDesktopUpdates), + Layer.provideMerge( + Layer.succeed(DesktopUpdates.DesktopUpdates, { ...desktopUpdates, ...overrides.updates }), + ), Layer.provide( Layer.mock(DesktopUpdateCommand.DesktopUpdateCommand, { request: (request) => @@ -128,9 +135,12 @@ const configureMenu = ( ok: true, status: "cancelled", }), + ...overrides.command, }), ), - Layer.provideMerge(layerElectronDialog), + Layer.provideMerge( + Layer.succeed(ElectronDialog.ElectronDialog, { ...electronDialog, ...overrides.dialog }), + ), Layer.provideMerge(layerElectronApp), Layer.provideMerge( DesktopEnvironment.layer({ ...environmentInput, ...environment }).pipe( @@ -142,6 +152,70 @@ const configureMenu = ( ); describe("DesktopApplicationMenu", () => { + it.effect("shows why the shared updater could not accept a menu request", () => + Effect.gen(function* () { + const template = yield* Deferred.make(); + const shown = yield* Deferred.make(); + yield* configureMenu( + yield* Deferred.make(), + template, + { platform: "darwin" }, + { + updates: { + check: () => + Effect.succeed({ + checked: true, + state: { + enabled: true, + status: "available", + channel: "latest", + currentVersion: "1.2.3", + availableVersion: "1.2.4", + downloadedVersion: null, + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }, + }), + }, + command: { + request: (request) => + Effect.succeed({ + version: 1, + requestId: request.requestId, + ok: false, + code: "renderer-unavailable", + message: "An update confirmation is already in progress.", + }), + }, + dialog: { + showMessageBox: (options) => + Deferred.succeed(shown, options).pipe( + Effect.as({ response: 0, checkboxChecked: false }), + ), + }, + }, + ); + const application = (yield* Deferred.await(template))[0]; + if (!Array.isArray(application?.submenu)) throw new Error("Expected app menu."); + const item = application.submenu.find((item) => item.label === "Check for Updates..."); + if (typeof item?.click !== "function") throw new Error("Expected update menu action."); + item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.deepInclude(yield* Deferred.await(shown), { + type: "warning", + title: "Could not update T3 Code", + detail: "An update confirmation is already in progress.", + }); + }), + ); + it.effect("leaves the accelerator to Chromium instead of injecting a paste", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 5d113ff74f73..d7f239046741 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -72,11 +72,20 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { const result = yield* updates.check("menu"); const updateState = result.state; if (updateState.status === "available" || updateState.status === "downloaded") { - yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).request({ + const response = yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).request({ version: 1, requestId: "menu-update", type: "update", }); + if (!response.ok) { + yield* electronDialog.showMessageBox({ + type: "warning", + title: "Could not update T3 Code", + message: "The coordinated updater is unavailable.", + detail: response.message, + buttons: ["OK"], + }); + } return; } diff --git a/apps/server/src/binMain.ts b/apps/server/src/binMain.ts index 4f58445ad407..881f56eeec52 100644 --- a/apps/server/src/binMain.ts +++ b/apps/server/src/binMain.ts @@ -31,7 +31,8 @@ export async function runCliCommand() { const { runNativeSpeechWorker } = await import("./speech/native.ts"); await runNativeSpeechWorker(mode, process.argv[4]); } else { - if (!(command === "update" && process.argv.includes("--check"))) enableCliCompileCache(); + if (!(process.argv.slice(2).includes("update") && process.argv.includes("--check"))) + enableCliCompileCache(); // Windows desktop launches without stdin; attach before reading, except on fast paths using pipes. const { attachConsoleStdin } = await import("./cli/consoleStdin.ts"); attachConsoleStdin(); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index cd3a2c629928..40e008150e29 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -141,12 +141,17 @@ async function startFakeDesktop< } const fakeDesktop = Effect.fn(function* ( - input: Omit[0], "platform" | "userId">, + input: Omit< + Parameters>[0], + "platform" | "userId" + >, ) { const platform = yield* HostProcess.Platform; const userId = yield* HostProcess.UserId; return yield* Effect.acquireRelease( - Effect.promise(() => startFakeDesktop({ ...input, platform, userId })), + Effect.promise(() => + startFakeDesktop({ ...input, platform, userId }), + ), (server) => Effect.promise(() => server.close()), ); }); @@ -407,29 +412,39 @@ it.effect("routes a legacy desktop shim's update and read-only check through des NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-update-")), ); const restore = setProcessEnv("T3CODE_CLI_PATH", NodePath.join(root, "bin/t3")); + let unsupported = false; const desktop = yield* Effect.promise(() => startFakeDesktop({ baseDir: root, platform, userId, - reply: (request) => ({ - version: 1, - requestId: request.requestId, - ok: true, - ...(request.type === "update-check" + reply: (request) => + unsupported ? { - check: { - schema: "t3.update-check.v1", - coordinated: true, - owner: "desktop", - pathway: "desktop-coordinated", - currentVersion: "0.1.7", - targetVersion: "0.1.8", - state: "ready", - }, + version: 1, + requestId: request.requestId, + ok: false, + code: "invalid-request", + message: "The desktop app request is invalid.", } - : { status: "cancelled" }), - }), + : { + version: 1, + requestId: request.requestId, + ok: true, + ...(request.type === "update-check" + ? { + check: { + schema: "t3.update-check.v1", + coordinated: true, + owner: "desktop", + pathway: "desktop-coordinated", + currentVersion: "0.1.7", + targetVersion: "0.1.8", + state: "ready", + }, + } + : { status: "cancelled" }), + }, }), ); try { @@ -450,6 +465,13 @@ it.effect("routes a legacy desktop shim's update and read-only check through des assert.isTrue(Exit.isFailure(refused)); } assert.equal(desktop.received.length, 3); + unsupported = true; + const oldDesktop = yield* runCli(["update", "--base-dir", root]).pipe(Effect.flip); + expect(oldDesktop).toMatchObject({ + _tag: "DesktopCliUpdateError", + reason: + "This desktop app cannot coordinate t3 update. Update the desktop app first. No standalone update was attempted.", + }); } finally { restore(); yield* Effect.promise(() => desktop.close()); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index 3b273761462b..0671238befc7 100644 --- a/apps/server/src/cli/app.ts +++ b/apps/server/src/cli/app.ts @@ -35,6 +35,13 @@ const CLI_RESPONSE_TIMEOUT_MS = 17_000; const MAX_RESPONSE_BYTES = 64 * 1024; const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse); const isDesktopUpdateCommandResponse = Schema.is(DesktopUpdateCommandResponse); +const isDesktopAppControlResponse = ( + response: unknown, + request: DesktopAppControlRequest, +): response is DesktopAppControlResponse => + request.type === "open-workspace" + ? isDesktopAppActivationResponse(response) + : isDesktopUpdateCommandResponse(response); export class DesktopAppSshUnsupportedError extends Schema.TaggedError()( "DesktopAppSshUnsupportedError", @@ -154,11 +161,7 @@ export async function sendDesktopAppControlRequest(input: { }); return; } - if ( - !(input.request.type === "open-workspace" - ? isDesktopAppActivationResponse(parsed) - : isDesktopUpdateCommandResponse(parsed)) - ) { + if (!isDesktopAppControlResponse(parsed, input.request)) { finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); return; } diff --git a/apps/server/src/cli/desktopUpdate.ts b/apps/server/src/cli/desktopUpdate.ts index 07c36c2b59c5..fab5aa2fa87a 100644 --- a/apps/server/src/cli/desktopUpdate.ts +++ b/apps/server/src/cli/desktopUpdate.ts @@ -61,8 +61,12 @@ export const requestDesktopUpdate = Effect.fn("cli.update.desktop")(function* (i } const result = response.value; if (!result.ok) { - if (input.check) return { ...check, reason: result.message }; - return yield* new DesktopCliUpdateError({ reason: result.message }); + const reason = + result.code === "invalid-request" + ? "This desktop app cannot coordinate t3 update. Update the desktop app first. No standalone update was attempted." + : result.message; + if (input.check) return { ...check, reason }; + return yield* new DesktopCliUpdateError({ reason }); } if ("check" in result) return result.check; if ("status" in result) return result.status; diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index cd3961fa02da..67fb8c658645 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,3 +1,4 @@ +import { T3UpdateCheck } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { CLI_RELEASE_BASE_URL_ENV, @@ -59,6 +60,7 @@ const ReleaseIndex = Schema.Array( draft: Schema.optional(Schema.Boolean), }), ); +const encodeUpdateCheck = Schema.encodeEffect(Schema.fromJsonString(T3UpdateCheck)); const decodeReleaseIndex = Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseIndex)); const RELEASE_INDEX_TIMEOUT = Duration.seconds(30); @@ -321,12 +323,12 @@ export const runUpdateCommand = Effect.fn("cli.update.command")(function* (flags ? "Update cancelled. No update was started." : "No updates are currently offered by this desktop updater." : flags.json - ? JSON.stringify(result) + ? yield* encodeUpdateCheck(result) : `${result.pathway}: ${result.state}. ${result.reason ?? ""}`, ); } if (flags.check) { - const result = { + const result: T3UpdateCheck = { schema: "t3.update-check.v1", coordinated: false, owner: "standalone", @@ -337,7 +339,7 @@ export const runUpdateCommand = Effect.fn("cli.update.command")(function* (flags reason: "This standalone CLI updates its own runtime and service; it does not coordinate a desktop app.", }; - return yield* Console.log(flags.json ? JSON.stringify(result) : result.reason); + return yield* Console.log(flags.json ? yield* encodeUpdateCheck(result) : result.reason); } const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); diff --git a/apps/server/src/cliCompileCache.test.ts b/apps/server/src/cliCompileCache.test.ts index 4b4d80f5b5a0..03abbd7f9ac4 100644 --- a/apps/server/src/cliCompileCache.test.ts +++ b/apps/server/src/cliCompileCache.test.ts @@ -70,6 +70,10 @@ it.live("loads the CLI with its cache settings only when the command needs it", coordinated: false, }); expect(yield* fs.exists(`${root}/home`)).toBe(false); + const globalFlags = yield* run({}, ["--log-level", "debug", "update", "--check", "--json"]); + expect(globalFlags.exitCode).toBe(0); + expect(globalFlags.stderr).toContain("CACHE=disabled"); + expect(yield* fs.exists(`${root}/home`)).toBe(false); const launcher = yield* run({ T3CODE_HOME: "" }, ["__service-launcher"]); expect(launcher.exitCode).toBe(1); expect(launcher.stderr).toContain("[service-launcher] T3CODE_HOME is required"); diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx index 7d62975a9c41..e34f55405c38 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx @@ -1,4 +1,8 @@ -import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; +import type { + DesktopUpdateCommandRequest, + DesktopUpdateCommandResponse, + DesktopUpdateState, +} from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { act, render, stubGlobal } from "../../../test/render"; @@ -19,29 +23,30 @@ vi.mock("../sidebar/useUpdateEverything", () => ({ }), })); +const state: DesktopUpdateState = { + enabled: true, + status: "available", + channel: "latest", + currentVersion: "0.1.7", + availableVersion: "0.1.8", + downloadedVersion: null, + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + it("uses one native confirmation and cancels without starting the coordinated runner", async () => { mocks.run.mockClear(); let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; const complete = vi.fn(async () => true); const finish = vi.fn(async () => undefined); - const state: DesktopUpdateState = { - enabled: true, - status: "available", - channel: "latest", - currentVersion: "0.1.7", - availableVersion: "0.1.8", - downloadedVersion: null, - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - releaseNotes: [], - omittedReleaseCount: 0, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - }; using _bridge = stubGlobal("desktopBridge", { getUpdateState: async () => state, updateCommand: { @@ -81,3 +86,53 @@ it("uses one native confirmation and cancels without starting the coordinated ru }); await vi.waitFor(() => expect(finish).toHaveBeenCalledOnce()); }); + +it("replaces a disconnected confirmation and ignores its late preparation", async () => { + mocks.run.mockClear(); + let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; + let releaseOld: ((value: DesktopUpdateState) => void) | undefined; + const oldState = new Promise((resolve) => { + releaseOld = resolve; + }); + const getUpdateState = vi + .fn() + .mockImplementationOnce(() => oldState) + .mockResolvedValue(state); + const complete = vi.fn( + async (response: DesktopUpdateCommandResponse) => response.requestId === "new", + ); + const finish = vi.fn(async () => undefined); + using _bridge = stubGlobal("desktopBridge", { + getUpdateState, + updateCommand: { + setReady: async () => undefined, + complete, + finish, + onRequest: (callback: typeof listener) => { + listener = callback; + return () => { + listener = undefined; + }; + }, + }, + }); + await using screen = await render(); + await act(async () => listener?.({ version: 1, requestId: "old", type: "update" })); + // The old caller disconnected while preparing; the native owner admitted its successor. + await act(async () => listener?.({ version: 1, requestId: "new", type: "update" })); + await expect.element(page.getByRole("alertdialog", { name: "Update 1 machine?" })).toBeVisible(); + await act(async () => releaseOld?.({ ...state, availableVersion: "obsolete" })); + await userEvent.click(screen.getByRole("button", { name: "Update", exact: true })); + await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledOnce()); + expect(complete).toHaveBeenCalledWith({ + version: 1, + requestId: "new", + ok: true, + status: "started", + }); + expect(complete).not.toHaveBeenCalledWith( + expect.objectContaining({ requestId: "old", status: "started" }), + ); + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith("new")); + expect(finish).not.toHaveBeenCalledWith("old"); +}); diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx index 5e67b80470a0..fbcb27847ff9 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -1,5 +1,6 @@ import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; -import { useEffect, useEffectEvent, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import { downloadLocalUpdate, installLocalUpdate } from "../sidebar/SidebarUpdatePill"; import { useUpdateEverything } from "../sidebar/useUpdateEverything"; import { UpdateEverythingDialog } from "../sidebar/UpdateEverythingDialog"; @@ -11,7 +12,8 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; export function DesktopUpdateCoordinator() { const command = window.desktopBridge?.updateCommand; - const [rendererId] = useState(() => crypto.randomUUID()); + const [rendererId] = useState(() => SecureRandom.uuidV4Unsafe()); + const latestRequestId = useRef(null); const [request, setRequest] = useState(null); const [prepared, setPrepared] = useState(null); const action = prepared?.enabled ? resolveDesktopUpdateButtonAction(prepared) : "none"; @@ -22,17 +24,11 @@ export function DesktopUpdateCoordinator() { const receive = useEffectEvent(async (incoming: DesktopUpdateCommandRequest) => { if (!command) return; const bridge = window.desktopBridge!; - if (request !== null) { - await command.complete({ - version: 1, - requestId: incoming.requestId, - ok: false, - code: "internal-error", - message: "An update is already in progress.", - }); - return; - } + // The host serializes requests. A newer token supersedes a cancelled or expired confirmation. + latestRequestId.current = incoming.requestId; + setRequest(null); let current = await bridge.getUpdateState(); + if (latestRequestId.current !== incoming.requestId) return; if ( current.status === "idle" || current.status === "up-to-date" || @@ -40,6 +36,7 @@ export function DesktopUpdateCoordinator() { ) { current = (await bridge.checkForUpdate()).state; } + if (latestRequestId.current !== incoming.requestId) return; if (current.status === "checking" || current.status === "downloading") { await command.complete({ version: 1, @@ -89,6 +86,7 @@ export function DesktopUpdateCoordinator() { }); void command.setReady(true, rendererId); return () => { + latestRequestId.current = null; unsubscribe(); void command.setReady(false, rendererId); }; @@ -119,20 +117,22 @@ export function DesktopUpdateCoordinator() { return; try { const bridge = window.desktopBridge!; - await everything.run({ - ...(action === "download" - ? { - downloadLocal: (reportError) => - downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), - } - : {}), - ...(action !== "none" - ? { - installLocal: (reportError) => - installLocalUpdate(bridge, reportError, localVersion ?? undefined), - } - : {}), - }); + await everything + .run({ + ...(action === "download" + ? { + downloadLocal: (reportError) => + downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + } + : {}), + ...(action !== "none" + ? { + installLocal: (reportError) => + installLocalUpdate(bridge, reportError, localVersion ?? undefined), + } + : {}), + }) + .finally(() => command.finish(currentRequest.requestId)); } catch (error) { toastManager.add( stackedThreadToast({ @@ -141,8 +141,6 @@ export function DesktopUpdateCoordinator() { description: error instanceof Error ? error.message : "The coordinated update failed.", }), ); - } finally { - await command.finish(currentRequest.requestId); } }; if (!request) return null; diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index 121555ba73d1..54d1cc4d85d3 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -107,60 +107,68 @@ function SwitchPlan({ ); return; } - try { - // Stay open while the feed is checked; the run's own progress takes over after. - setChecking(true); - const fail = (description: string) => - toastManager.add( - stackedThreadToast({ type: "error", title: `Could not switch to ${label}`, description }), - ); - const prepared = await prepareLocalTrackSwitch(bridge, { - channel: release.channel, - version: release.version, - reinstall: switchesLocal, - }); - onClose(); - if (!prepared.ok) { - fail(prepared.message); - return; - } - const { local } = prepared; - const result = await settlePromise(() => - everything.run( - local === null - ? {} - : { - ...(local.status === "available" - ? { - downloadLocal: (reportError) => - downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + await Promise.resolve() + .then(async () => { + // Stay open while the feed is checked; the run's own progress takes over after. + setChecking(true); + const fail = (description: string) => + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not switch to ${label}`, + description, + }), + ); + const prepared = await prepareLocalTrackSwitch(bridge, { + channel: release.channel, + version: release.version, + reinstall: switchesLocal, + }); + onClose(); + if (!prepared.ok) { + fail(prepared.message); + return; + } + const { local } = prepared; + const result = await settlePromise(() => + everything.run( + local === null + ? {} + : { + ...(local.status === "available" + ? { + downloadLocal: (reportError) => + downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + } + : {}), + // Install only a build the database check covers, pinned so nothing replaces it. + installLocal: async (reportError) => { + const { downloadedVersion } = await bridge.getUpdateState(); + if ( + downloadedVersion === null || + !acceptsTrackSwitchRelease( + downloadedVersion, + release.channel, + release.version, + ) + ) { + reportError( + "Could not install update", + `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, + ); + return false; } - : {}), - // Install only a build the database check covers, pinned so nothing replaces it. - installLocal: async (reportError) => { - const { downloadedVersion } = await bridge.getUpdateState(); - if ( - downloadedVersion === null || - !acceptsTrackSwitchRelease(downloadedVersion, release.channel, release.version) - ) { - reportError( - "Could not install update", - `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, - ); - return false; - } - return installLocalUpdate(bridge, reportError, downloadedVersion); + return installLocalUpdate(bridge, reportError, downloadedVersion); + }, }, - }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - fail(error instanceof Error ? error.message : "An unexpected error occurred."); - } - } finally { - await bridge.updateCommand.finish(reservation); - } + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + fail(error instanceof Error ? error.message : "An unexpected error occurred."); + } + }) + .finally(() => bridge.updateCommand.finish(reservation)); }; return ( From a7b504f44098b62493d8a7b2d94599d211ca6962 Mon Sep 17 00:00:00 2001 From: trahloc Date: Sat, 7 Nov 2026 11:44:34 +0100 Subject: [PATCH 03/14] fix(desktop): recover coordinated updates after renderer reload Keep document ownership across component remounts and reclaim abandoned renderer reservations only after native work settles. Route every manual desktop update check through the shared planner. --- apps/desktop/src/ipc/methods/updates.ts | 2 +- .../src/updates/DesktopUpdateCommand.test.ts | 107 ++++++++++++------ .../src/updates/DesktopUpdateCommand.ts | 52 +++++++-- .../src/window/DesktopApplicationMenu.test.ts | 61 ++++++++++ .../src/window/DesktopApplicationMenu.ts | 67 +++-------- .../desktop/DesktopUpdateCoordinator.tsx | 6 +- .../desktop/desktopUpdateRequest.ts | 8 ++ .../settings/SettingsPanels.browser.test.tsx | 87 ++++++++++++++ .../components/settings/SettingsPanels.tsx | 45 +------- .../SidebarUpdatePill.browser.test.tsx | 19 ++++ .../components/sidebar/SidebarUpdatePill.tsx | 4 +- docs/user/updating.md | 3 + 12 files changed, 319 insertions(+), 142 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsPanels.browser.test.tsx diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index 7e67efdea81d..2db9ad96fc56 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -102,7 +102,7 @@ export const updateCommandReserve = DesktopIpc.makeIpcMethod({ payload: Schema.Void, result: Schema.NullOr(Schema.String), handler: Effect.fn("desktop.ipc.updateCommand.reserve")(function* () { - return yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).reserve; + return yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).reserveForRenderer; }), }); export const updateCommandFinish = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.test.ts b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts index 039e3dcb6ed4..2baa7c27df4e 100644 --- a/apps/desktop/src/updates/DesktopUpdateCommand.test.ts +++ b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts @@ -7,6 +7,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopUpdateCommand from "./DesktopUpdateCommand.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; @@ -29,13 +30,16 @@ const state: DesktopUpdateState = { errorContext: null, canRetry: false, }; -const layer = (send: (request: DesktopUpdateCommandRequest) => void) => +const layer = ( + send: (request: DesktopUpdateCommandRequest) => void, + isActionActive = Effect.succeed(false), +) => DesktopUpdateCommand.layer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(DesktopUpdates.DesktopUpdates, { getState: Effect.succeed(state), - isActionActive: Effect.succeed(false), + isActionActive, }), Layer.mock(ElectronWindow.ElectronWindow, { main: Effect.succeedSome({ @@ -63,6 +67,8 @@ it.effect("checks cached coordinator availability without activating, checking o }); const first = yield* command.reserve; assert.isNotNull(first); + yield* command.setReady(true, "replacement"); + assert.isNull(yield* command.reserve); assert.equal((yield* command.inspect).state, "busy"); yield* command.finish(first!); const second = yield* command.reserve; @@ -70,6 +76,17 @@ it.effect("checks cached coordinator availability without activating, checking o yield* command.finish(first!); assert.equal((yield* command.inspect).state, "busy"); yield* command.finish(second!); + const rendererReservation = yield* command.reserveForRenderer; + assert.isNotNull(rendererReservation); + yield* command.setReady(false, "replacement"); + yield* command.setReady(true, "replacement"); + assert.isNull(yield* command.reserveForRenderer); + yield* command.setReady(true, "new-document"); + const resumed = yield* command.reserveForRenderer; + assert.isNotNull(resumed); + yield* command.finish(rendererReservation!); + assert.equal((yield* command.inspect).state, "busy"); + yield* command.finish(resumed!); }).pipe( Effect.provide( layer(() => { @@ -200,34 +217,60 @@ it.effect("an older request's cleanup cannot clear or release its successor", () }), ); -it.effect("keeps a started run reserved when its renderer unregisters", () => - Effect.gen(function* () { - const sent = yield* Deferred.make(); - yield* Effect.gen(function* () { - const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; - yield* command.setReady(true, "renderer"); - const response = yield* command - .request({ version: 1, requestId: "running", type: "update" }) - .pipe(Effect.forkScoped); - const incoming = yield* Deferred.await(sent); - yield* command.complete({ - version: 1, - requestId: incoming.requestId, - ok: true, - status: "started", - }); - assert.deepInclude(yield* Fiber.join(response), { requestId: "running", status: "started" }); - yield* command.setReady(false, "renderer"); - yield* command.setReady(true, "replacement"); - assert.isNull(yield* command.reserve); - yield* command.finish(incoming.requestId); - assert.equal((yield* command.inspect).state, "ready"); - }).pipe( - Effect.provide( - layer((request) => { - Deferred.doneUnsafe(sent, Effect.succeed(request)); - }), - ), - ); - }), +it.effect( + "recovers an abandoned renderer run after native work settles, without its old finally", + () => + Effect.gen(function* () { + const firstSent = yield* Deferred.make(); + const secondSent = yield* Deferred.make(); + const nativeActive = yield* Ref.make(false); + let sent = 0; + yield* Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + yield* command.setReady(true, "renderer"); + const response = yield* command + .request({ version: 1, requestId: "running", type: "update" }) + .pipe(Effect.forkScoped); + const incoming = yield* Deferred.await(firstSent); + yield* command.complete({ + version: 1, + requestId: incoming.requestId, + ok: true, + status: "started", + }); + yield* Fiber.join(response); + // A component remount in the same document does not abandon its running promise. + yield* command.setReady(false, "renderer"); + yield* command.setReady(true, "renderer"); + assert.isNull(yield* command.reserve); + yield* Ref.set(nativeActive, true); + // A real renderer replacement loses that promise, but native work must still finish. + yield* command.setReady(false, "renderer"); + yield* command.setReady(true, "replacement"); + assert.isNull(yield* command.reserve); + assert.equal((yield* command.inspect).state, "busy"); + yield* Ref.set(nativeActive, false); + assert.equal((yield* command.inspect).state, "ready"); + const retry = yield* command + .request({ version: 1, requestId: "retry", type: "update" }) + .pipe(Effect.forkScoped); + const successor = yield* Deferred.await(secondSent); + yield* command.finish(incoming.requestId); + yield* command.setReady(false, "renderer"); + assert.equal((yield* command.inspect).state, "busy"); + yield* command.complete({ + version: 1, + requestId: successor.requestId, + ok: true, + status: "cancelled", + }); + assert.deepInclude(yield* Fiber.join(retry), { requestId: "retry", status: "cancelled" }); + }).pipe( + Effect.provide( + layer((request) => { + Deferred.doneUnsafe(sent++ === 0 ? firstSent : secondSent, Effect.succeed(request)); + }, Ref.get(nativeActive)), + ), + ); + }), ); diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.ts b/apps/desktop/src/updates/DesktopUpdateCommand.ts index 5827f0fb78f8..34598e60ab80 100644 --- a/apps/desktop/src/updates/DesktopUpdateCommand.ts +++ b/apps/desktop/src/updates/DesktopUpdateCommand.ts @@ -25,6 +25,7 @@ export class DesktopUpdateCommand extends Context.Service< readonly cancel: (callerId: string) => Effect.Effect; readonly complete: (response: DesktopUpdateCommandResponse) => Effect.Effect; readonly reserve: Effect.Effect; + readonly reserveForRenderer: Effect.Effect; readonly finish: (reservation: string) => Effect.Effect; } >()("@t3tools/desktop/updates/DesktopUpdateCommand") {} @@ -35,14 +36,19 @@ interface PendingCommand { readonly reply: Deferred.Deferred; } +interface UpdateReservation { + readonly owner: string | null; + readonly rendererId: string | null; + readonly next: number; +} + const make = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const windows = yield* ElectronWindow.ElectronWindow; const ready = yield* Ref.make(null); - const lock = yield* Ref.make<{ readonly owner: string | null; readonly next: number }>({ - owner: null, - next: 0, - }); + const lock = yield* Ref.make({ owner: null, rendererId: null, next: 0 }); + const isAbandoned = (reservation: UpdateReservation, rendererId: string | null) => + reservation.rendererId !== null && rendererId !== null && reservation.rendererId !== rendererId; const pending = yield* Ref.make>(Option.none()); const finish = (reservation: string) => Ref.update(lock, (current) => @@ -78,22 +84,33 @@ const make = Effect.gen(function* () { }); return true; }); - const reserve = Effect.gen(function* () { + const reserveOwned = Effect.fn("DesktopUpdateCommand.reserve")(function* ( + rendererId: string | null, + ) { if (yield* updates.isActionActive) return null; + const registeredRenderer = yield* Ref.get(ready); return yield* Ref.modify(lock, (current) => { - if (current.owner !== null) return [null, current] as const; + if (current.owner !== null && !isAbandoned(current, registeredRenderer)) + return [null, current] as const; const owner = `desktop-update-${current.next}`; - return [owner, { owner, next: current.next + 1 }] as const; + return [owner, { owner, rendererId, next: current.next + 1 }] as const; }); }); + const reserve = reserveOwned(null); + const reserveForRenderer = Effect.gen(function* () { + const rendererId = yield* Ref.get(ready); + return rendererId === null ? null : yield* reserveOwned(rendererId); + }); const inspect = Effect.gen(function* () { const state = yield* updates.getState; const main = yield* windows.main; + const registeredRenderer = yield* Ref.get(ready); const available = - (yield* Ref.get(ready)) !== null && - Option.isSome(main) && - !main.value.webContents.isDestroyed(); - const occupied = (yield* Ref.get(lock)).owner !== null || (yield* updates.isActionActive); + registeredRenderer !== null && Option.isSome(main) && !main.value.webContents.isDestroyed(); + const reservation = yield* Ref.get(lock); + const occupied = + (reservation.owner !== null && !isAbandoned(reservation, registeredRenderer)) || + (yield* updates.isActionActive); const targetVersion = state.downloadedVersion ?? state.availableVersion; return { schema: "t3.update-check.v1", @@ -119,6 +136,7 @@ const make = Effect.gen(function* () { return DesktopUpdateCommand.of({ inspect, reserve, + reserveForRenderer, finish, complete, cancel: Effect.fn("DesktopUpdateCommand.cancel")(function* (callerId) { @@ -129,7 +147,17 @@ const make = Effect.gen(function* () { }), setReady: Effect.fn("DesktopUpdateCommand.setReady")(function* (value, rendererId) { if (value) { + const reservation = yield* Ref.get(lock); yield* Ref.set(ready, rendererId); + const current = yield* Ref.get(pending); + if ( + isAbandoned(reservation, rendererId) && + Option.isSome(current) && + current.value.requestId === reservation.owner + ) + yield* complete( + failure(current.value.requestId, "The desktop update coordinator was replaced."), + ); return; } if ((yield* Ref.get(ready)) !== rendererId) return; @@ -146,7 +174,7 @@ const make = Effect.gen(function* () { request.requestId, "Open or update T3 Code, then run t3 update again. No standalone update was attempted.", ); - const reservation = yield* reserve; + const reservation = yield* reserveForRenderer; if (reservation === null) return failure( request.requestId, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 2d10a11158fb..94615ce62e51 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -216,6 +216,67 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("plans remote and provider updates even when this app is current", () => + Effect.gen(function* () { + const template = yield* Deferred.make(); + const outcome = yield* Deferred.make(); + yield* configureMenu( + yield* Deferred.make(), + template, + { platform: "darwin" }, + { + updates: { + check: () => + Effect.succeed({ + checked: true, + state: { + enabled: true, + status: "up-to-date", + channel: "latest", + currentVersion: "1.2.3", + availableVersion: null, + downloadedVersion: null, + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }, + }), + }, + command: { + request: (request) => + Deferred.succeed(outcome, "remote/provider plan").pipe( + Effect.as({ + version: 1, + requestId: request.requestId, + ok: true, + status: "started", + }), + ), + }, + dialog: { + showMessageBox: () => + Deferred.succeed(outcome, "local-only status").pipe( + Effect.as({ response: 0, checkboxChecked: false }), + ), + }, + }, + ); + const application = (yield* Deferred.await(template))[0]; + if (!Array.isArray(application?.submenu)) throw new Error("Expected app menu."); + const item = application.submenu.find((item) => item.label === "Check for Updates..."); + if (typeof item?.click !== "function") throw new Error("Expected update menu action."); + item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(outcome), "remote/provider plan"); + }), + ); + it.effect("leaves the accelerator to Chromium instead of injecting a paste", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index d7f239046741..64e5f9b0c559 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -2,7 +2,6 @@ import * as DesktopUpdateCommand from "../updates/DesktopUpdateCommand.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as Electron from "electron"; @@ -11,7 +10,6 @@ import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; export class DesktopApplicationMenuActionError extends Schema.TaggedError()( @@ -35,12 +33,9 @@ export class DesktopApplicationMenu extends Context.Service< type DesktopApplicationMenuRuntimeServices = | DesktopUpdateCommand.DesktopUpdateCommand - | DesktopUpdates.DesktopUpdates | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; -const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater"); - const { logError: logMenuError } = makeComponentLogger("desktop-menu"); const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( @@ -67,65 +62,31 @@ const runMainContentsCommand = Effect.fn("desktop.menu.runMainContentsCommand")( }); const checkForUpdatesFromMenu = Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; - const result = yield* updates.check("menu"); - const updateState = result.state; - if (updateState.status === "available" || updateState.status === "downloaded") { - const response = yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).request({ - version: 1, - requestId: "menu-update", - type: "update", - }); - if (!response.ok) { - yield* electronDialog.showMessageBox({ - type: "warning", - title: "Could not update T3 Code", - message: "The coordinated updater is unavailable.", - detail: response.message, - buttons: ["OK"], - }); - } - return; - } - - if (updateState.status === "up-to-date") { - yield* electronDialog.showMessageBox({ - type: "info", - title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, - buttons: ["OK"], - }); - } else if (updateState.status === "error") { + const response = yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).request({ + version: 1, + requestId: "menu-update", + type: "update", + }); + if (!response.ok) { yield* electronDialog.showMessageBox({ type: "warning", - title: "Update check failed", - message: "Could not check for updates.", - detail: updateState.message ?? "An unknown error occurred. Please try again later.", + title: "Could not update T3 Code", + message: "The coordinated updater could not complete the request.", + detail: response.message, buttons: ["OK"], }); - } -}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); - -const handleCheckForUpdatesMenuClick = Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const disabledReason = yield* updates.disabledReason; - if (Option.isSome(disabledReason)) { - yield* logUpdaterInfo("manual update check requested, but updates are disabled", { - disabledReason: disabledReason.value, - }); + } else if ("status" in response && response.status === "up-to-date") { yield* electronDialog.showMessageBox({ type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason.value, + title: "No updates available", + message: "No updates are currently offered.", buttons: ["OK"], }); - return; } +}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); - // Opening a window before backend confirmation can strand it on failed auth bootstrap. +const handleCheckForUpdatesMenuClick = Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx index fbcb27847ff9..7ab4ab6c5666 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -10,9 +10,11 @@ import { } from "../desktopUpdate.logic"; import { stackedThreadToast, toastManager } from "../ui/toast"; +// The document owns the runner; component remounts must not transfer its reservation. +const rendererId = SecureRandom.uuidV4Unsafe(); + export function DesktopUpdateCoordinator() { const command = window.desktopBridge?.updateCommand; - const [rendererId] = useState(() => SecureRandom.uuidV4Unsafe()); const latestRequestId = useRef(null); const [request, setRequest] = useState(null); const [prepared, setPrepared] = useState(null); @@ -90,7 +92,7 @@ export function DesktopUpdateCoordinator() { unsubscribe(); void command.setReady(false, rendererId); }; - }, [command, rendererId]); + }, [command]); const cancel = async () => { if (!request || !command) return; diff --git a/apps/web/src/components/desktop/desktopUpdateRequest.ts b/apps/web/src/components/desktop/desktopUpdateRequest.ts index 3e3bce9b8384..a14a0ab0202a 100644 --- a/apps/web/src/components/desktop/desktopUpdateRequest.ts +++ b/apps/web/src/components/desktop/desktopUpdateRequest.ts @@ -6,6 +6,14 @@ export async function requestCoordinatedDesktopUpdate(): Promise { if (!command) throw new Error("Update T3 Code to use its coordinated updater."); const response = await command.request(); if (!response.ok) throw new Error(response.message); + if ("status" in response && response.status === "up-to-date") + toastManager.add( + stackedThreadToast({ + type: "info", + title: "No updates available", + description: "No updates are currently offered.", + }), + ); } catch (error) { toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/components/settings/SettingsPanels.browser.test.tsx b/apps/web/src/components/settings/SettingsPanels.browser.test.tsx new file mode 100644 index 000000000000..63f280c29b70 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.browser.test.tsx @@ -0,0 +1,87 @@ +import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { render, stubGlobal } from "../../../test/render"; +import "../../index.css"; +import { AboutSettingsPanel } from "./SettingsPanels"; +import { DesktopUpdateCoordinator } from "../desktop/DesktopUpdateCoordinator"; + +const mocks = vi.hoisted(() => ({ state: null as DesktopUpdateState | null })); +vi.mock("../../state/desktopUpdate", async (original) => ({ + ...(await original()), + useDesktopUpdateState: () => mocks.state, +})); +vi.mock("../../env", async (original) => ({ + ...(await original()), + isElectron: true, +})); +vi.mock("../sidebar/useUpdateEverything", () => ({ + isHostedByThisApp: () => false, + useUpdateEverything: () => ({ + hasUpdates: true, + version: null, + providerCount: 1, + rows: [ + { + environmentId: null, + label: "Remote machine", + app: "server", + skipped: null, + providers: ["Codex"], + }, + ], + restarts: [], + }), +})); + +it("About checks the coordinated plan for remote/provider offers when the app is current", async () => { + const state: DesktopUpdateState = { + enabled: true, + status: "up-to-date", + channel: "latest", + currentVersion: "0.1.7", + availableVersion: null, + downloadedVersion: null, + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; + mocks.state = state; + let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; + using _bridge = stubGlobal("desktopBridge", { + getUpdateState: async () => state, + checkForUpdate: async () => ({ checked: true, state }), + getLocalEnvironmentBootstraps: () => [], + updateCommand: { + setReady: async () => undefined, + complete: async () => true, + request: async () => { + listener?.({ version: 1, requestId: "about", type: "update" }); + return { version: 1, requestId: "about", ok: true, status: "cancelled" }; + }, + onRequest: (callback: typeof listener) => { + listener = callback; + return () => { + listener = undefined; + }; + }, + }, + }); + await using screen = await render( + <> + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Check for Updates", exact: true })); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + await expect.element(page.getByText("Remote machine", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Codex", { exact: true })).toBeVisible(); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 55a2b00ace0e..8243465083d2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -188,45 +188,10 @@ function AboutVersionSection() { ).length; const handleButtonClick = async () => { - const bridge = window.desktopBridge; - if (!bridge) return; - - const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; - - if (action === "download" || action === "install") { - if (isUpdateActionPending) return; - setIsUpdateActionPending(true); - await requestCoordinatedDesktopUpdate(); - setIsUpdateActionPending(false); - return; - } - - if (typeof bridge.checkForUpdate !== "function") return; - void bridge - .checkForUpdate() - .then((result) => { - if (!result.checked) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not check for updates", - description: - updatesDenial ?? - result.state.message ?? - "Automatic updates are not available in this build.", - }), - ); - } - }) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not check for updates", - description: error instanceof Error ? error.message : "Update check failed.", - }), - ); - }); + if (!window.desktopBridge || isUpdateActionPending) return; + setIsUpdateActionPending(true); + await requestCoordinatedDesktopUpdate(); + setIsUpdateActionPending(false); }; const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; @@ -240,8 +205,6 @@ function AboutVersionSection() { const statusLabel: Record = { checking: "Checking…", downloading: "Downloading…", - // Off its track, "up to date" only means no switch is pending; Review moves it. - ...(appOffTrack ? {} : { "up-to-date": "Up to Date" }), }; const buttonLabel = actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 1798d2bed927..46099eca314c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -146,6 +146,25 @@ describe("SidebarUpdatePill release notes", () => { }); describe("SidebarUpdatePill update everything", () => { + it("uses the shared check when no local or remote offers are known", async () => { + setup({ ...nightlyUpdate([]), status: "up-to-date", availableVersion: null }); + const request = vi.fn(async () => ({ + version: 1, + requestId: "sidebar", + ok: true, + status: "up-to-date", + })); + const localCheck = vi.fn(async () => ({ checked: true, state: mocks.update })); + using _bridge = stubGlobal("desktopBridge", { + updateCommand: { request }, + checkForUpdate: localCheck, + }); + await using screen = await render(); + await userEvent.click(screen.getByRole("button", { name: "Check for updates", exact: true })); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + expect(localCheck).not.toHaveBeenCalled(); + }); + it("does not refocus the update button after a pointer cancel", async () => { setup(null, { hasUpdates: true }); using _bridge = coordinateUpdates(); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 2f12d36dacb0..b64e2aacea48 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -276,8 +276,10 @@ function SidebarUpdateControl() { const bridge = window.desktopBridge; if (isInteractionDisabled) return; - if (isElectron && (runsEverything || action === "download" || action === "install")) { + if (isElectron) { + setIsActionPending(true); await requestCoordinatedDesktopUpdate(); + setIsActionPending(false); return; } if (runsEverything) { diff --git a/docs/user/updating.md b/docs/user/updating.md index 61abf83e08b8..3e0345386e6b 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -36,6 +36,9 @@ The confirmation counts the running threads that the restarts will interrupt, and how many can resume afterwards. See **Continue threads after restarts** below. +Manual checks from the sidebar, **Settings → About**, and the desktop menu review the same +connected servers and providers even when this app is current. + ## Update from a terminal The `t3` command installed by the desktop app uses the same coordinated updater as the sidebar and From 994a0b7484cd7b52f06373ce2b9f8ba0f30324f4 Mon Sep 17 00:00:00 2001 From: trahloc Date: Sat, 7 Nov 2026 13:30:42 +0100 Subject: [PATCH 04/14] fix(desktop): start update control with local environment off Start the scoped app control server after creating the paired-client window without configuring or starting a local backend. Preserve enabled-local startup ordering and verify both modes clean up the control resource. --- apps/desktop/src/app/DesktopApp.test.ts | 101 ++++++++++++++++-------- apps/desktop/src/app/DesktopApp.ts | 10 ++- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 7c3e634954f6..c62f2afb808e 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -71,9 +71,13 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as MacPermissions from "../permissions/MacPermissions.ts"; import * as PreviewManager from "../preview/Manager.ts"; -it.effect.each([false, true])( - "stops backends together before flushing history and cancels Linux registration, stop defect=%s", - (stopDefect) => +it.effect.each([ + { localEnvironmentEnabled: true, stopDefect: false }, + { localEnvironmentEnabled: true, stopDefect: true }, + { localEnvironmentEnabled: false, stopDefect: false }, +])( + "owns controls through shutdown; local=$localEnvironmentEnabled, stop defect=$stopDefect", + ({ localEnvironmentEnabled, stopDefect }) => Effect.gen(function* () { const registrationStarted = yield* Deferred.make(); const releaseRegistration = yield* Deferred.make(); @@ -85,6 +89,8 @@ it.effect.each([false, true])( const historyStarted = yield* Deferred.make(); const releaseHistory = yield* Deferred.make(); const quitting = yield* Ref.make(false); + const bootstrapped = yield* Deferred.make(); + const shutdown = yield* DesktopShutdown.DesktopShutdown; const events: string[] = []; const record = (event: string) => Effect.sync(() => { @@ -93,6 +99,12 @@ it.effect.each([false, true])( const runApp = DesktopApp.program .pipe( Effect.provide(NodeCrypto.layer), + Effect.provideService(DesktopShutdown.DesktopShutdown, { + ...shutdown, + awaitRequest: Deferred.succeed(bootstrapped, undefined).pipe( + Effect.andThen(shutdown.awaitRequest), + ), + }), Effect.provideService(DesktopAppIdentity.DesktopAppIdentity, { resolveUserDataPath: Effect.succeed("/isolated/userdata"), configure: record("identity"), @@ -123,7 +135,7 @@ it.effect.each([false, true])( Effect.provideService(DesktopAppSettings.DesktopAppSettings, { load: Effect.void, get: Effect.succeed({ - localEnvironmentEnabled: true, + localEnvironmentEnabled, serverExposureMode: "local-only", }), } as unknown as DesktopAppSettings.DesktopAppSettings["Service"]), @@ -167,40 +179,52 @@ it.effect.each([false, true])( quitting, } as DesktopState.DesktopState["Service"]), Effect.provideService(DesktopWindow.DesktopWindow, { - ensureMain: record("window").pipe( - Effect.andThen(Deferred.succeed(windowStarted, undefined)), - ), + ensureMain: localEnvironmentEnabled + ? record("window").pipe(Effect.andThen(Deferred.succeed(windowStarted, undefined))) + : Effect.die("disabled local backend must not ensure a backend window"), + createMainIfBackendReady: !localEnvironmentEnabled + ? record("window").pipe(Effect.andThen(Deferred.succeed(windowStarted, undefined))) + : Effect.die("enabled startup must retain its backend-first ordering"), } as unknown as DesktopWindow.DesktopWindow["Service"]), Effect.provideService(DesktopBackendPool.DesktopBackendPool, { - primary: Effect.succeed({ - start: record("backend"), - currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }), - }), - list: Effect.succeed([ - { - stop: () => - Deferred.succeed(firstStopStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseStops)), - Effect.andThen(stopDefect ? Effect.die("stop defect") : Effect.void), - ), - }, - { - stop: () => - Deferred.succeed(secondStopStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseStops)), - ), - }, - ]), + primary: localEnvironmentEnabled + ? Effect.succeed({ + start: record("backend"), + currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }), + }) + : Effect.die("disabled local environment must not access the primary backend"), + list: Effect.succeed( + !localEnvironmentEnabled + ? [] + : [ + { + stop: () => + Deferred.succeed(firstStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + Effect.andThen(stopDefect ? Effect.die("stop defect") : Effect.void), + ), + }, + { + stop: () => + Deferred.succeed(secondStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + ), + }, + ], + ), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]), Effect.provideService(DesktopServerExposure.DesktopServerExposure, { - configureFromSettings: () => Effect.succeed({ mode: "local-only" }), + configureFromSettings: () => + localEnvironmentEnabled + ? Effect.succeed({ mode: "local-only" }) + : Effect.die("disabled local environment must not configure a server"), backendConfig: Effect.succeed({ httpBaseUrl: new URL("http://127.0.0.1:3773") }), } as unknown as DesktopServerExposure.DesktopServerExposure["Service"]), Effect.provideService(DesktopWslBackend.DesktopWslBackend, { reconcile: Effect.void, } as DesktopWslBackend.DesktopWslBackend["Service"]), Effect.provideService(DesktopAppActivation.DesktopAppActivation, { - start: Effect.void, + start: Effect.acquireRelease(record("control"), () => record("control-closed")), } as unknown as DesktopAppActivation.DesktopAppActivation["Service"]), Effect.provideService(DesktopRendererHistory.DesktopRendererHistory, { shutdown: Deferred.succeed(historyStarted, undefined).pipe( @@ -249,13 +273,16 @@ it.effect.each([false, true])( ); yield* Deferred.await(registrationStarted); yield* Deferred.await(windowStarted); - assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); + yield* Deferred.await(bootstrapped); + const startupEvents = [...events]; + assert.isFalse(yield* Deferred.isDone(registrationStopped)); - const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* shutdown.request; - yield* Deferred.await(firstStopStarted); - yield* Deferred.await(secondStopStarted); - assert.isFalse(yield* Deferred.isDone(historyStarted)); + if (localEnvironmentEnabled) { + yield* Deferred.await(firstStopStarted); + yield* Deferred.await(secondStopStarted); + assert.isFalse(yield* Deferred.isDone(historyStarted)); + } assert.isFalse(yield* shutdown.isComplete); yield* Deferred.succeed(releaseStops, undefined); yield* Deferred.await(historyStarted); @@ -265,5 +292,13 @@ it.effect.each([false, true])( assert.equal(Exit.isFailure(result), stopDefect); assert.isTrue(yield* shutdown.isComplete); assert.isTrue(yield* Deferred.isDone(registrationStopped)); + assert.deepEqual( + startupEvents, + localEnvironmentEnabled + ? ["identity", "ready", "identity", "backend", "window", "control"] + : ["identity", "ready", "identity", "window", "control"], + ); + + assert.equal(events.filter((event) => event === "control-closed").length, 1); }).pipe(Effect.provide(DesktopShutdown.layer)), ); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 3c5f499a1cb3..3dd64098fda0 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -173,6 +173,10 @@ const bootstrap = Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; + const startAppControl = appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; @@ -197,6 +201,7 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); if (!(yield* Ref.get(state.quitting))) { yield* desktopWindow.createMainIfBackendReady; + yield* startAppControl; } return; } @@ -261,10 +266,7 @@ const bootstrap = Effect.gen(function* () { ) { yield* desktopWindow.ensureMain; } - yield* appActivation.start.pipe( - Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), - Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), - ); + yield* startAppControl; yield* Effect.forkScoped(wslBackend.reconcile); } }).pipe(Effect.withSpan("desktop.bootstrap")); From 729c148a4f6e869dd83aa063dc6870936e84dd9b Mon Sep 17 00:00:00 2001 From: trahloc Date: Sun, 8 Nov 2026 16:29:38 +0100 Subject: [PATCH 05/14] fix(desktop): retain coordinated update ownership across reloads Keep accepted runs reserved until terminal cleanup, independently of renderer identity and local updater activity. Let prepared remote installs wait for a background check while preserving shared exclusion. Keep update requests available during onboarding. Preserve a guarded local menu check before renderer readiness, and explain expired confirmations. Address CLI error classification, alias help, cache routing and review docs. Co-Authored-By: Codex --- .../src/app/DesktopAppActivation.test.ts | 6 +- .../src/updates/DesktopRemoteUpdates.test.ts | 6 +- .../src/updates/DesktopRemoteUpdates.ts | 14 ++- .../src/updates/DesktopUpdateCommand.test.ts | 54 +++++++++- .../src/updates/DesktopUpdateCommand.ts | 61 +++++++++--- apps/desktop/src/updates/DesktopUpdates.ts | 4 + .../src/window/DesktopApplicationMenu.test.ts | 95 ++++++++++++++++++ .../src/window/DesktopApplicationMenu.ts | 36 ++++++- apps/server/src/binMain.ts | 8 +- apps/server/src/cli/app.test.ts | 98 ++++++++++++++++++- apps/server/src/cli/desktopUpdate.ts | 54 ++++++++-- apps/server/src/cli/service.ts | 16 ++- apps/server/src/cli/update.ts | 2 +- apps/server/src/cliCompileCache.test.ts | 17 ++++ .../DesktopUpdateCoordinator.browser.test.tsx | 32 ++++++ .../desktop/DesktopUpdateCoordinator.tsx | 10 +- .../sidebar/updateEverything.logic.test.ts | 23 +++++ .../sidebar/updateEverything.logic.ts | 12 ++- apps/web/src/routes/__root.tsx | 2 +- docs/operations/release.md | 2 +- docs/user/updating.md | 21 ++-- 21 files changed, 513 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index 405f3b03e78e..95c5dbd09797 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -8,7 +8,7 @@ import { ProjectId, ThreadId, type DesktopAppActivationRequest, - type DesktopAppActivationResponse, + type DesktopAppControlResponse, } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -64,7 +64,7 @@ function startOkServer(target: ReturnType, userId: number | u } function exchange(address: string, payload: unknown) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const socket = NodeNet.createConnection(address); socket.setEncoding("utf8"); let buffer = ""; @@ -75,7 +75,7 @@ function exchange(address: string, payload: unknown) { const newline = buffer.indexOf("\n"); if (newline === -1) return; socket.destroy(); - resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse); + resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppControlResponse); }); }); } diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index d036cf2944b0..d903913799d5 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -79,11 +79,7 @@ function runRemoteUpdatesTest( Effect.provide( Layer.mergeAll( TestClock.layer(), - harness.layer, - Layer.mock(DesktopUpdateCommand.DesktopUpdateCommand, { - reserve: Effect.succeed("test-reservation"), - finish: () => Effect.void, - }), + DesktopUpdateCommand.layer.pipe(Layer.provideMerge(harness.layer)), ), ), ); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index 800a103dae0b..c43ee37b5b26 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -140,11 +140,15 @@ export const listen: Effect.Effect< Effect.gen(function* () { const reservation = yield* command.reserve; if (reservation === null) { + const prepared = yield* Ref.get(preparedUpdateRef); yield* publishReport( yield* updates.getState, { outcome: "failed", - reason: "A desktop update or confirmation is already in progress.", + reason: + Option.isSome(prepared) && prepared.value.status === "committing" + ? "A prepared desktop update is already in progress." + : "A desktop update or confirmation is already in progress.", }, request.requestId, ); @@ -500,7 +504,13 @@ export const listen: Effect.Effect< } return; } - const reservation = yield* command.reserve; + if (yield* updates.isInstallActive) { + yield* logInfo("remote update commit joining an in-progress install", { + requestId: commit.requestId, + }); + return; + } + const reservation = yield* command.reserveForPreparedInstall; if (reservation === null) { const reason = "A desktop update or confirmation is already in progress."; yield* recordPreparedFailure(commit.requestId, reason); diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.test.ts b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts index 2baa7c27df4e..2a935e256f24 100644 --- a/apps/desktop/src/updates/DesktopUpdateCommand.test.ts +++ b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts @@ -82,6 +82,8 @@ it.effect("checks cached coordinator availability without activating, checking o yield* command.setReady(true, "replacement"); assert.isNull(yield* command.reserveForRenderer); yield* command.setReady(true, "new-document"); + assert.isNull(yield* command.reserveForRenderer); + yield* command.finish(rendererReservation!); const resumed = yield* command.reserveForRenderer; assert.isNotNull(resumed); yield* command.finish(rendererReservation!); @@ -218,12 +220,14 @@ it.effect("an older request's cleanup cannot clear or release its successor", () ); it.effect( - "recovers an abandoned renderer run after native work settles, without its old finally", + "holds the run lease after local download completes while remote work survives renderer replacement", () => Effect.gen(function* () { const firstSent = yield* Deferred.make(); const secondSent = yield* Deferred.make(); const nativeActive = yield* Ref.make(false); + const remoteStarted = yield* Deferred.make(); + const releaseRemote = yield* Deferred.make(); let sent = 0; yield* Effect.gen(function* () { const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; @@ -244,12 +248,23 @@ it.effect( yield* command.setReady(true, "renderer"); assert.isNull(yield* command.reserve); yield* Ref.set(nativeActive, true); - // A real renderer replacement loses that promise, but native work must still finish. + const remoteRun = yield* Deferred.succeed(remoteStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRemote)), + Effect.ensuring(command.finish(incoming.requestId)), + Effect.forkScoped, + ); + yield* Deferred.await(remoteStarted); + yield* Ref.set(nativeActive, false); yield* command.setReady(false, "renderer"); yield* command.setReady(true, "replacement"); assert.isNull(yield* command.reserve); + assert.isNull(yield* command.reserveForRenderer); + assert.isFalse( + (yield* command.request({ version: 1, requestId: "overlapping", type: "update" })).ok, + ); assert.equal((yield* command.inspect).state, "busy"); - yield* Ref.set(nativeActive, false); + yield* Deferred.succeed(releaseRemote, undefined); + yield* Fiber.join(remoteRun); assert.equal((yield* command.inspect).state, "ready"); const retry = yield* command .request({ version: 1, requestId: "retry", type: "update" }) @@ -274,3 +289,36 @@ it.effect( ); }), ); + +it.effect("request interruption cannot release an accepted run", () => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + yield* Effect.gen(function* () { + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + yield* command.setReady(true, "renderer"); + const request = yield* command + .request({ version: 1, requestId: "interrupted", type: "update" }) + .pipe(Effect.forkScoped); + const incoming = yield* Deferred.await(sent); + assert.isTrue( + yield* command.complete({ + version: 1, + requestId: incoming.requestId, + ok: true, + status: "started", + }), + ); + yield* Fiber.interrupt(request); + assert.equal((yield* command.inspect).state, "busy"); + assert.isNull(yield* command.reserve); + yield* command.finish(incoming.requestId); + assert.equal((yield* command.inspect).state, "ready"); + }).pipe( + Effect.provide( + layer((request) => { + Deferred.doneUnsafe(sent, Effect.succeed(request)); + }), + ), + ); + }), +); diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.ts b/apps/desktop/src/updates/DesktopUpdateCommand.ts index 34598e60ab80..a3806be6cf11 100644 --- a/apps/desktop/src/updates/DesktopUpdateCommand.ts +++ b/apps/desktop/src/updates/DesktopUpdateCommand.ts @@ -25,6 +25,7 @@ export class DesktopUpdateCommand extends Context.Service< readonly cancel: (callerId: string) => Effect.Effect; readonly complete: (response: DesktopUpdateCommandResponse) => Effect.Effect; readonly reserve: Effect.Effect; + readonly reserveForPreparedInstall: Effect.Effect; readonly reserveForRenderer: Effect.Effect; readonly finish: (reservation: string) => Effect.Effect; } @@ -40,19 +41,31 @@ interface UpdateReservation { readonly owner: string | null; readonly rendererId: string | null; readonly next: number; + readonly phase: "confirming" | "running" | null; } const make = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const windows = yield* ElectronWindow.ElectronWindow; const ready = yield* Ref.make(null); - const lock = yield* Ref.make({ owner: null, rendererId: null, next: 0 }); + const lock = yield* Ref.make({ + owner: null, + rendererId: null, + next: 0, + phase: null, + }); const isAbandoned = (reservation: UpdateReservation, rendererId: string | null) => reservation.rendererId !== null && rendererId !== null && reservation.rendererId !== rendererId; const pending = yield* Ref.make>(Option.none()); const finish = (reservation: string) => Ref.update(lock, (current) => - current.owner === reservation ? { ...current, owner: null } : current, + current.owner === reservation ? { ...current, owner: null, phase: null } : current, + ); + const finishConfirmation = (reservation: string) => + Ref.update(lock, (current) => + current.owner === reservation && current.phase === "confirming" + ? { ...current, owner: null, phase: null } + : current, ); const clearPending = (reservation: string) => Ref.update(pending, (current) => @@ -76,8 +89,14 @@ const make = Effect.gen(function* () { : ([Option.none(), value] as const), ); if (Option.isNone(current)) return false; - if (!response.ok || !("status" in response) || response.status !== "started") - yield* finish(current.value.requestId); + if (response.ok && "status" in response && response.status === "started") { + const accepted = yield* Ref.modify(lock, (reservation) => + reservation.owner === response.requestId && reservation.phase === "confirming" + ? ([true, { ...reservation, phase: "running" as const }] as const) + : ([false, reservation] as const), + ); + if (!accepted) return false; + } else yield* finishConfirmation(current.value.requestId); yield* Deferred.succeed(current.value.reply, { ...response, requestId: current.value.callerId, @@ -86,17 +105,19 @@ const make = Effect.gen(function* () { }); const reserveOwned = Effect.fn("DesktopUpdateCommand.reserve")(function* ( rendererId: string | null, + phase: "confirming" | "running" = "running", + allowCheck = false, ) { - if (yield* updates.isActionActive) return null; - const registeredRenderer = yield* Ref.get(ready); + if ((yield* updates.isActionActive) && !(allowCheck && (yield* updates.isCheckActive))) + return null; return yield* Ref.modify(lock, (current) => { - if (current.owner !== null && !isAbandoned(current, registeredRenderer)) - return [null, current] as const; + if (current.owner !== null) return [null, current] as const; const owner = `desktop-update-${current.next}`; - return [owner, { owner, rendererId, next: current.next + 1 }] as const; + return [owner, { owner, rendererId, phase, next: current.next + 1 }] as const; }); }); const reserve = reserveOwned(null); + const reserveForPreparedInstall = reserveOwned(null, "running", true); const reserveForRenderer = Effect.gen(function* () { const rendererId = yield* Ref.get(ready); return rendererId === null ? null : yield* reserveOwned(rendererId); @@ -108,9 +129,7 @@ const make = Effect.gen(function* () { const available = registeredRenderer !== null && Option.isSome(main) && !main.value.webContents.isDestroyed(); const reservation = yield* Ref.get(lock); - const occupied = - (reservation.owner !== null && !isAbandoned(reservation, registeredRenderer)) || - (yield* updates.isActionActive); + const occupied = reservation.owner !== null || (yield* updates.isActionActive); const targetVersion = state.downloadedVersion ?? state.availableVersion; return { schema: "t3.update-check.v1", @@ -130,12 +149,21 @@ const make = Effect.gen(function* () { : "ready", reason: !available ? "Open or update T3 Code so its coordinated updater is available." - : "Cached desktop update state only; connected machines and providers have not been checked.", + : occupied + ? reservation.phase === "running" && isAbandoned(reservation, registeredRenderer) + ? "A coordinated update outlived its interface. Check the affected machines before restarting T3 Code; the run cannot be safely reclaimed." + : "A T3 Code update or confirmation is already in progress." + : !state.enabled + ? (state.message ?? "Desktop updates are disabled.") + : state.status === "error" + ? (state.message ?? "The desktop update check failed.") + : "Cached desktop update state only; connected machines and providers have not been checked.", } satisfies T3UpdateCheck; }); return DesktopUpdateCommand.of({ inspect, reserve, + reserveForPreparedInstall, reserveForRenderer, finish, complete, @@ -174,7 +202,8 @@ const make = Effect.gen(function* () { request.requestId, "Open or update T3 Code, then run t3 update again. No standalone update was attempted.", ); - const reservation = yield* reserveForRenderer; + const rendererId = yield* Ref.get(ready); + const reservation = yield* reserveOwned(rendererId, "confirming"); if (reservation === null) return failure( request.requestId, @@ -212,10 +241,10 @@ const make = Effect.gen(function* () { Effect.tap((response) => response.ok && "status" in response && response.status === "started" ? Effect.void - : finish(reservation), + : finishConfirmation(reservation), ), Effect.ensuring(clearPending(reservation)), - Effect.onInterrupt(() => finish(reservation)), + Effect.onInterrupt(() => finishConfirmation(reservation)), ); }), }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 14688e525806..ae2a0d69de40 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -187,6 +187,7 @@ export class DesktopUpdates extends Context.Service< { readonly getState: Effect.Effect; readonly isActionActive: Effect.Effect; + readonly isCheckActive: Effect.Effect; readonly isInstallActive: Effect.Effect; readonly subscribe: Effect.Effect< { @@ -1099,6 +1100,9 @@ export const make = Effect.gen(function* () { return DesktopUpdates.of({ getState: Ref.get(updateStateRef), isActionActive: activeUpdateAction.pipe(Effect.map(Option.isSome)), + isCheckActive: activeUpdateAction.pipe( + Effect.map((action) => Option.isSome(action) && action.value === "check"), + ), isInstallActive: activeUpdateAction.pipe( Effect.map((action) => Option.isSome(action) && action.value === "install"), ), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 94615ce62e51..175f81458098 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -62,6 +62,7 @@ const electronDialog = ElectronDialog.ElectronDialog.of({ const desktopUpdates = DesktopUpdates.DesktopUpdates.of({ getState: Effect.die("unexpected getState"), isActionActive: Effect.succeed(false), + isCheckActive: Effect.succeed(false), isInstallActive: Effect.succeed(false), subscribe: Effect.die("unexpected subscribe"), emitState: Effect.void, @@ -128,6 +129,15 @@ const configureMenu = ( ), Layer.provide( Layer.mock(DesktopUpdateCommand.DesktopUpdateCommand, { + inspect: Effect.succeed({ + schema: "t3.update-check.v1", + owner: "desktop", + pathway: "desktop-coordinated", + coordinated: true, + currentVersion: "1.2.3", + targetVersion: null, + state: "ready", + } as const), request: (request) => Effect.succeed({ version: 1, @@ -152,6 +162,91 @@ const configureMenu = ( ); describe("DesktopApplicationMenu", () => { + it.effect( + "checks locally before a renderer is ready, without bypassing a held update lease", + () => + Effect.gen(function* () { + for (const busy of [false, true]) { + const template = yield* Deferred.make(); + const shown = yield* Deferred.make(); + let checks = 0; + yield* configureMenu( + yield* Deferred.make(), + template, + { platform: "darwin" }, + { + command: { + inspect: Effect.succeed({ + schema: "t3.update-check.v1", + owner: "desktop", + pathway: "desktop-coordinated", + coordinated: false, + currentVersion: "1.2.3", + targetVersion: null, + state: "unavailable", + }), + reserve: Effect.succeed(busy ? null : "startup-check"), + finish: () => Effect.void, + request: (request) => + Effect.succeed({ + version: 1, + requestId: request.requestId, + ok: false, + code: "renderer-unavailable", + message: "The renderer is not ready.", + }), + }, + updates: { + check: () => + Effect.sync(() => { + checks += 1; + return { + checked: true, + state: { + enabled: true, + status: "up-to-date" as const, + channel: "latest" as const, + currentVersion: "1.2.3", + availableVersion: null, + downloadedVersion: null, + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }, + }; + }), + }, + dialog: { + showMessageBox: (options) => + Deferred.succeed(shown, options).pipe( + Effect.as({ response: 0, checkboxChecked: false }), + ), + }, + }, + ); + const application = (yield* Deferred.await(template))[0]; + if (!Array.isArray(application?.submenu)) throw new Error("Expected app menu."); + const item = application.submenu.find((item) => item.label === "Check for Updates..."); + if (typeof item?.click !== "function") throw new Error("Expected update menu action."); + item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + const message = yield* Deferred.await(shown); + assert.equal(checks, busy ? 0 : 1); + assert.equal(message.type, busy ? "warning" : "info"); + assert.include( + `${message.message} ${message.detail ?? ""}`, + busy ? "already in progress" : "Connected machines and providers have not been checked", + ); + } + }), + ); + it.effect("shows why the shared updater could not accept a menu request", () => Effect.gen(function* () { const template = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 64e5f9b0c559..45d6eb118753 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -1,4 +1,5 @@ import * as DesktopUpdateCommand from "../updates/DesktopUpdateCommand.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -33,6 +34,7 @@ export class DesktopApplicationMenu extends Context.Service< type DesktopApplicationMenuRuntimeServices = | DesktopUpdateCommand.DesktopUpdateCommand + | DesktopUpdates.DesktopUpdates | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; @@ -63,7 +65,39 @@ const runMainContentsCommand = Effect.fn("desktop.menu.runMainContentsCommand")( const checkForUpdatesFromMenu = Effect.gen(function* () { const electronDialog = yield* ElectronDialog.ElectronDialog; - const response = yield* (yield* DesktopUpdateCommand.DesktopUpdateCommand).request({ + const command = yield* DesktopUpdateCommand.DesktopUpdateCommand; + if (!(yield* command.inspect).coordinated) { + const reservation = yield* command.reserve; + if (reservation === null) { + yield* electronDialog.showMessageBox({ + type: "warning", + title: "Could not check for updates", + message: "A T3 Code update or confirmation is already in progress.", + detail: "Wait for that update to finish before checking again.", + buttons: ["OK"], + }); + return; + } + const updates = yield* DesktopUpdates.DesktopUpdates; + const { checked, state } = yield* updates + .check("menu") + .pipe(Effect.ensuring(command.finish(reservation))); + const failed = !state.enabled || state.status === "error" || !checked; + yield* electronDialog.showMessageBox({ + type: failed ? "warning" : "info", + title: failed ? "Could not check for updates" : "Desktop update check", + message: failed + ? (state.message ?? "The desktop update check could not start.") + : state.availableVersion !== null || state.downloadedVersion !== null + ? "A desktop update is available." + : "No desktop update is currently offered.", + detail: + "Connected machines and providers have not been checked. Open T3 Code to review and install coordinated updates.", + buttons: ["OK"], + }); + return; + } + const response = yield* command.request({ version: 1, requestId: "menu-update", type: "update", diff --git a/apps/server/src/binMain.ts b/apps/server/src/binMain.ts index 881f56eeec52..3eb75e2989d3 100644 --- a/apps/server/src/binMain.ts +++ b/apps/server/src/binMain.ts @@ -31,8 +31,14 @@ export async function runCliCommand() { const { runNativeSpeechWorker } = await import("./speech/native.ts"); await runNativeSpeechWorker(mode, process.argv[4]); } else { - if (!(process.argv.slice(2).includes("update") && process.argv.includes("--check"))) + const args = process.argv.slice(2); + let index = 0; + while (args[index] === "--log-level" || args[index]?.startsWith("--log-level=")) { + index += args[index] === "--log-level" ? 2 : 1; + } + if (!(args[index] === "update" && args.slice(index + 1).includes("--check"))) { enableCliCompileCache(); + } // Windows desktop launches without stdin; attach before reading, except on fast paths using pipes. const { attachConsoleStdin } = await import("./cli/consoleStdin.ts"); attachConsoleStdin(); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 40e008150e29..253188119c2b 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -23,7 +23,9 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Exit from "effect/Exit"; import { Command } from "effect/cli"; -import { describe, expect } from "vite-plus/test"; +import { describe, expect, vi } from "vite-plus/test"; +import * as appControl from "./app.ts"; +import { requestDesktopUpdate } from "./desktopUpdate.ts"; import { makeCli } from "../binCli.ts"; import { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; @@ -479,3 +481,97 @@ it.effect("routes a legacy desktop shim's update and read-only check through des } }), ); + +it.effect("classifies desktop update transport failures without exposing paths", () => + Effect.gen(function* () { + using send = vi.spyOn(appControl, "sendDesktopAppControlRequest"); + const unavailable = Object.assign(new Error("secret /private/socket"), { code: "ENOENT" }); + const refused = Object.assign(new Error("secret /private/socket"), { code: "ECONNREFUSED" }); + const unknown = Object.assign(new Error("secret /private/socket"), { code: "EACCES" }); + const cases = [ + [unavailable, "unavailable", "Open or update T3 Code"], + [refused, "unavailable", "Open or update T3 Code"], + [unknown, "error", "Could not communicate"], + [ + new Error("The desktop app socket directory is not private or owned by this user."), + "error", + "socket directory is not private", + ], + [new Error("The desktop app response is too large."), "error", "response is too large"], + [new Error("The desktop app response is not valid JSON."), "error", "not valid JSON"], + [new Error("The desktop app response is invalid."), "error", "response is invalid"], + [new Error("The desktop app response did not match this request."), "error", "did not match"], + [new Error("The desktop app did not respond in time."), "error", "did not confirm"], + [new Error("The desktop app closed the connection."), "error", "did not confirm"], + ] as const; + for (const [error, state, reason] of cases) { + send.mockRejectedValue(error); + const check = yield* requestDesktopUpdate({ + baseDir: "/secret", + currentVersion: "1", + check: true, + }); + if (typeof check === "string") return yield* Effect.die("Expected an update-check response."); + expect(check.state).toBe(state); + expect(check.reason).toContain(reason); + expect(check.reason).not.toContain("/secret"); + expect(check.reason).not.toContain("/private"); + expect(check.reason).toContain("No standalone update was attempted."); + const failure = yield* requestDesktopUpdate({ + baseDir: "/secret", + currentVersion: "1", + check: false, + }).pipe(Effect.flip); + expect(failure).toMatchObject({ _tag: "DesktopCliUpdateError", reason: check.reason }); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "reports a non-private desktop socket directory in check mode without writing state", + () => + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + const userId = yield* HostProcess.UserId; + if (platform === "win32") return; + const root = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-private-check-")), + ); + const address = resolveDesktopAppControlAddress({ + stateDir: NodePath.join(root, "userdata"), + platform, + userId, + tempDir: NodeOS.tmpdir(), + joinPath: NodePath.join, + }); + if (address.directory === null) return; + try { + yield* Effect.promise(() => + NodeFSP.mkdir(address.directory!, { recursive: true, mode: 0o755 }), + ); + yield* Effect.promise(() => NodeFSP.chmod(address.directory!, 0o755)); + const check = yield* requestDesktopUpdate({ + baseDir: root, + currentVersion: "1", + check: true, + }); + if (typeof check === "string") + return yield* Effect.die("Expected an update-check response."); + expect(check.state).toBe("error"); + expect(check.reason).toContain("socket directory is not private"); + expect(yield* Effect.promise(() => NodeFSP.readdir(root))).toEqual([]); + } finally { + yield* Effect.promise(() => + NodeFSP.rm(address.directory!, { recursive: true, force: true }), + ); + yield* Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("uses the generic update error for json without check", () => + Effect.gen(function* () { + const failure = yield* runCli(["update", "--json"]).pipe(Effect.flip); + expect(failure).toMatchObject({ _tag: "CliUpdateError", reason: "--json requires --check." }); + }), +); diff --git a/apps/server/src/cli/desktopUpdate.ts b/apps/server/src/cli/desktopUpdate.ts index fab5aa2fa87a..136b55bd378e 100644 --- a/apps/server/src/cli/desktopUpdate.ts +++ b/apps/server/src/cli/desktopUpdate.ts @@ -17,6 +17,46 @@ export class DesktopCliUpdateError extends Schema.TaggedError new DesktopCliUpdateError({ reason: check.reason! }), - }).pipe(Effect.option); - if (response._tag === "None") { - if (input.check) return check; - return yield* new DesktopCliUpdateError({ reason: check.reason! }); + catch: transportFailure, + }).pipe( + Effect.match({ onFailure: (failure) => ({ failure }), onSuccess: (result) => ({ result }) }), + ); + if ("failure" in response) { + if (input.check) return { ...check, ...response.failure }; + return yield* new DesktopCliUpdateError({ reason: response.failure.reason }); } - const result = response.value; + const result = response.result; if (!result.ok) { const reason = result.code === "invalid-request" diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index d9d91878a034..421f1c989b49 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -240,11 +240,23 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ), ); -const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( +const serviceUpdateFlags = { + ...projectLocationFlags, + allowDowngrade: Flag.Boolean("allow-downgrade").pipe( + Flag.withDescription( + "Allow updating to the newest channel release even if it is older than the installed version.", + ), + Flag.withDefault(false), + ), +}; + +const serviceUpdateCommand = Command.make("update", serviceUpdateFlags).pipe( Command.withDescription("Deprecated alias for t3 update."), Command.unlisted, Command.withHandler((flags) => - Console.log("`t3 service update` is deprecated; using `t3 update`.").pipe( + Console.log( + "`t3 service update` is deprecated; it selects the newest channel release via `t3 update`. To install the invoking CLI version, use `t3 service install`.", + ).pipe( Effect.andThen( runUpdateCommand({ ...flags, diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 67fb8c658645..79c7648eb920 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -295,7 +295,7 @@ export const runUpdateCommand = Effect.fn("cli.update.command")(function* (flags const desktopOwned = environment.T3CODE_DESKTOP_CLI === "1" || Boolean(environment.T3CODE_CLI_PATH?.trim()); if (flags.json && !flags.check) - return yield* new DesktopCliUpdateError({ reason: "--json requires --check." }); + return yield* new CliUpdateError({ reason: "--json requires --check." }); if (desktopOwned) { if ( Option.isSome(flags.version) || diff --git a/apps/server/src/cliCompileCache.test.ts b/apps/server/src/cliCompileCache.test.ts index 03abbd7f9ac4..e8919d185574 100644 --- a/apps/server/src/cliCompileCache.test.ts +++ b/apps/server/src/cliCompileCache.test.ts @@ -74,6 +74,23 @@ it.live("loads the CLI with its cache settings only when the command needs it", expect(globalFlags.exitCode).toBe(0); expect(globalFlags.stderr).toContain("CACHE=disabled"); expect(yield* fs.exists(`${root}/home`)).toBe(false); + const nested = yield* run({}, ["service", "update", "--check"]); + expect(nested.stderr.replaceAll("\\", "/")).toContain( + `CACHE=${root}/cache/t3code/compile-cache`.replaceAll("\\", "/"), + ); + const pathArgument = yield* run({}, ["--base-dir", "update", "--check"]); + expect(pathArgument.stderr.replaceAll("\\", "/")).toContain( + `CACHE=${root}/cache/t3code/compile-cache`.replaceAll("\\", "/"), + ); + const equalsFlag = yield* run({}, [ + "--log-level=debug", + "update", + "--base-dir", + `${root}/other`, + "--check", + "--json", + ]); + expect(equalsFlag.stderr).toContain("CACHE=disabled"); const launcher = yield* run({ T3CODE_HOME: "" }, ["__service-launcher"]); expect(launcher.exitCode).toBe(1); expect(launcher.stderr).toContain("[service-launcher] T3CODE_HOME is required"); diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx index e34f55405c38..61df47c3d774 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx @@ -8,6 +8,7 @@ import { page, userEvent } from "vite-plus/test/browser"; import { act, render, stubGlobal } from "../../../test/render"; import "../../index.css"; import { DesktopUpdateCoordinator } from "./DesktopUpdateCoordinator"; +import { ToastProvider } from "../ui/toast"; const mocks = vi.hoisted(() => ({ run: vi.fn(async () => undefined) })); vi.mock("../sidebar/useUpdateEverything", () => ({ @@ -136,3 +137,34 @@ it("replaces a disconnected confirmation and ignores its late preparation", asyn await vi.waitFor(() => expect(finish).toHaveBeenCalledWith("new")); expect(finish).not.toHaveBeenCalledWith("old"); }); + +it("explains an expired confirmation without starting an update", async () => { + mocks.run.mockClear(); + let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; + using _bridge = stubGlobal("desktopBridge", { + getUpdateState: async () => state, + updateCommand: { + setReady: async () => undefined, + complete: async () => false, + finish: async () => undefined, + onRequest: (callback: typeof listener) => { + listener = callback; + return () => { + listener = undefined; + }; + }, + }, + }); + await using screen = await render( + + + , + ); + await act(async () => listener?.({ version: 1, requestId: "expired", type: "update" })); + await expect.element(page.getByRole("alertdialog", { name: "Update 1 machine?" })).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Update", exact: true })); + await expect + .element(page.getByText("This update request expired or was replaced. Start the update again.")) + .toBeVisible(); + expect(mocks.run).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx index 7ab4ab6c5666..49d0293675b8 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -115,8 +115,16 @@ export function DesktopUpdateCoordinator() { ok: true, status: "started", })) - ) + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update T3 Code", + description: "This update request expired or was replaced. Start the update again.", + }), + ); return; + } try { const bridge = window.desktopBridge!; await everything diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 58328dbc1eee..a04ff72bf612 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -316,6 +316,29 @@ describe("collectRestartingMachines", () => { }); describe("runUpdateEverything", () => { + it("waits for remote work to settle before reporting a rejected local download", async () => { + const remote = Promise.withResolvers(); + const local = Promise.withResolvers(); + const localFailure = new Error("download failed unexpectedly"); + let finished = false; + const result = runUpdateEverything({ + downloadLocal: () => local.promise, + updateProviders: () => remote.promise, + updateServers: async () => "handed-off", + }).then( + () => undefined, + (error: unknown) => { + finished = true; + return error; + }, + ); + local.reject(localFailure); + await local.promise.catch(() => undefined).then(() => undefined); + expect(finished).toBe(false); + remote.resolve(); + expect(await result).toBe(localFailure); + }); + it("still updates remote machines when the local download fails", async () => { const calls: string[] = []; const result = await runUpdateEverything({ diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index cbbd3baa7c77..5bd63c723c8b 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -360,13 +360,15 @@ export async function runUpdateEverything( readonly providers: Providers; readonly servers: Servers; }> { - const [localReady, remote] = await Promise.all([ - steps.downloadLocal?.() ?? Promise.resolve(true), - steps - .updateProviders() + const [local, remote] = await Promise.allSettled([ + Promise.resolve().then(() => steps.downloadLocal?.() ?? true), + Promise.resolve() + .then(() => steps.updateProviders()) .then(async (providers) => ({ providers, servers: await steps.updateServers() })), ]); - return { localReady, ...remote }; + if (local.status === "rejected") throw local.reason; + if (remote.status === "rejected") throw remote.reason; + return { localReady: local.value, ...remote.value }; } export type UpdateRunMachineStatus = diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4ee7192976c3..6fe86061f10e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -135,6 +135,7 @@ function RootRouteView() { return ( + {isElectron ? : null} @@ -194,7 +195,6 @@ function RootRouteBranch() { return ( <> - {isElectron ? : null} service update` once on the +verify that the update stops before restart and run `npx t3@ service install` once on the server machine. Also test the manual or desktop-managed guidance when those environments are available. diff --git a/docs/user/updating.md b/docs/user/updating.md index 3e0345386e6b..c729b81b00d4 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -38,6 +38,8 @@ below. Manual checks from the sidebar, **Settings → About**, and the desktop menu review the same connected servers and providers even when this app is current. +Before the app's interface is ready, the desktop menu can check this app only; +open the app to review and install updates for connected machines too. ## Update from a terminal @@ -45,20 +47,17 @@ The `t3` command installed by the desktop app uses the same coordinated updater Settings → About. Run `t3 update` with the app open, then review its confirmation in the app. Cancelling the confirmation starts no update. Another update cannot start while that confirmation or run is active. The command reports when the run starts; follow its progress and restart result in the app. +If the interface reloads during a run, another update stays blocked. Check the affected +machines before restarting T3 Code and retrying; a reload does not cancel their updates. Desktop updates use the app's update track and confirmation. Exact versions, `--channel`, `--allow-downgrade`, and `--yes` are refused for this command; switch tracks in Settings → About. -If the desktop app is closed or too old to coordinate the request, the command asks you to open or -update it. It does not install a separate CLI runtime instead. A standalone CLI without a desktop -launcher retains `t3 update` and its background-service restart options. The deprecated -`t3 service update` command follows the same routing. - -Maintenance tools can run `t3 update --check --json`. The `t3.update-check.v1` result reports the -installation owner, coordinated-updater availability, cached local version and offered target, and -readiness. This inspection does not check the network, open the app, download, install, restart, or -rewrite launchers. `ready` means the desktop coordinator is available, not that an update exists or -that every connected server and provider has been checked. A closed or old app reports unavailable; -a standalone CLI reports its separate update pathway. Use the normal update action for a fresh check. +If the desktop app is closed or too old to coordinate the request, open or update it and try again. +The deprecated `t3 service update` command follows the same routing. For a standalone CLI, it +selects the newest release on its channel; use `t3 service install` to install the invoking CLI version. + +Run `t3 update --check --json` to inspect cached update availability without changing the installation. +Use the normal update action for a fresh check. The startup recovery screen can still install an app update when the normal interface cannot load. That recovery updates this app only; it cannot plan updates for unavailable connections. From 54ecd47c50a10061ceb2b00636067e0c33f7d119 Mon Sep 17 00:00:00 2001 From: trahloc Date: Sun, 8 Nov 2026 16:40:16 +0100 Subject: [PATCH 06/14] test(web): keep update regression compatible with ES2022 --- .../sidebar/updateEverything.logic.test.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index a04ff72bf612..527acc4793b7 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -317,13 +317,19 @@ describe("collectRestartingMachines", () => { describe("runUpdateEverything", () => { it("waits for remote work to settle before reporting a rejected local download", async () => { - const remote = Promise.withResolvers(); - const local = Promise.withResolvers(); + let releaseRemote: (() => void) | undefined; + let rejectLocal: ((error: Error) => void) | undefined; + const remote = new Promise((resolve) => { + releaseRemote = resolve; + }); + const local = new Promise((_resolve, reject) => { + rejectLocal = reject; + }); const localFailure = new Error("download failed unexpectedly"); let finished = false; const result = runUpdateEverything({ - downloadLocal: () => local.promise, - updateProviders: () => remote.promise, + downloadLocal: () => local, + updateProviders: () => remote, updateServers: async () => "handed-off", }).then( () => undefined, @@ -332,10 +338,10 @@ describe("runUpdateEverything", () => { return error; }, ); - local.reject(localFailure); - await local.promise.catch(() => undefined).then(() => undefined); + rejectLocal?.(localFailure); + await local.catch(() => undefined).then(() => undefined); expect(finished).toBe(false); - remote.resolve(); + releaseRemote?.(); expect(await result).toBe(localFailure); }); From a42ae4a0afab372ba8533280639d68a86fa551ff Mon Sep 17 00:00:00 2001 From: trahloc Date: Sun, 8 Nov 2026 17:21:17 +0100 Subject: [PATCH 07/14] fix(desktop): wait for background checks during remote preparation Keep remote preparation and commit under the same check-tolerant reservation. Surface recovery guidance for an orphaned run and correct service launcher repair instructions. --- .../src/updates/DesktopRemoteUpdates.test.ts | 33 +++++++++++++++++++ .../src/updates/DesktopRemoteUpdates.ts | 4 +-- .../src/updates/DesktopUpdateCommand.test.ts | 10 ++++-- .../src/updates/DesktopUpdateCommand.ts | 16 +++++---- apps/server/src/cloud/servicePreflight.ts | 2 +- docs/user/updating.md | 4 +-- 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index d903913799d5..71cd2f34a2be 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -672,6 +672,39 @@ describe("DesktopRemoteUpdates", () => { ); }); + it.effect("waits for a background check before preparing a remote update", () => { + const checkStarted = Deferred.makeUnsafe(); + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const background = yield* updates.check("poll").pipe(Effect.forkChild); + yield* Deferred.await(checkStarted); + yield* Queue.offer(requests, request("prepare-during-poll")); + yield* settle; + assert.isEmpty(terminalReports(reports)); + yield* Deferred.succeed(releaseCheck, undefined); + yield* Fiber.join(background); + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + yield* TestClock.adjust(Duration.millis(300)); + yield* settle; + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + it.effect("waits for a background check before installing a prepared update", () => { const backgroundCheckStarted = Deferred.makeUnsafe(); const releaseBackgroundCheck = Deferred.makeUnsafe(); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index c43ee37b5b26..4f5792fc5418 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -138,7 +138,7 @@ export const listen: Effect.Effect< const handleRequest = (request: DesktopTelemetryRequestDesktopUpdate): Effect.Effect => Effect.scoped( Effect.gen(function* () { - const reservation = yield* command.reserve; + const reservation = yield* command.reserveForRemoteUpdate; if (reservation === null) { const prepared = yield* Ref.get(preparedUpdateRef); yield* publishReport( @@ -510,7 +510,7 @@ export const listen: Effect.Effect< }); return; } - const reservation = yield* command.reserveForPreparedInstall; + const reservation = yield* command.reserveForRemoteUpdate; if (reservation === null) { const reason = "A desktop update or confirmation is already in progress."; yield* recordPreparedFailure(commit.requestId, reason); diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.test.ts b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts index 2a935e256f24..ad67e6b0c05f 100644 --- a/apps/desktop/src/updates/DesktopUpdateCommand.test.ts +++ b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts @@ -259,9 +259,13 @@ it.effect( yield* command.setReady(true, "replacement"); assert.isNull(yield* command.reserve); assert.isNull(yield* command.reserveForRenderer); - assert.isFalse( - (yield* command.request({ version: 1, requestId: "overlapping", type: "update" })).ok, - ); + const refused = yield* command.request({ + version: 1, + requestId: "overlapping", + type: "update", + }); + assert.isFalse(refused.ok); + if (!refused.ok) assert.include(refused.message, "outlived its interface"); assert.equal((yield* command.inspect).state, "busy"); yield* Deferred.succeed(releaseRemote, undefined); yield* Fiber.join(remoteRun); diff --git a/apps/desktop/src/updates/DesktopUpdateCommand.ts b/apps/desktop/src/updates/DesktopUpdateCommand.ts index a3806be6cf11..5f14059187ac 100644 --- a/apps/desktop/src/updates/DesktopUpdateCommand.ts +++ b/apps/desktop/src/updates/DesktopUpdateCommand.ts @@ -25,7 +25,7 @@ export class DesktopUpdateCommand extends Context.Service< readonly cancel: (callerId: string) => Effect.Effect; readonly complete: (response: DesktopUpdateCommandResponse) => Effect.Effect; readonly reserve: Effect.Effect; - readonly reserveForPreparedInstall: Effect.Effect; + readonly reserveForRemoteUpdate: Effect.Effect; readonly reserveForRenderer: Effect.Effect; readonly finish: (reservation: string) => Effect.Effect; } @@ -117,7 +117,7 @@ const make = Effect.gen(function* () { }); }); const reserve = reserveOwned(null); - const reserveForPreparedInstall = reserveOwned(null, "running", true); + const reserveForRemoteUpdate = reserveOwned(null, "running", true); const reserveForRenderer = Effect.gen(function* () { const rendererId = yield* Ref.get(ready); return rendererId === null ? null : yield* reserveOwned(rendererId); @@ -151,7 +151,7 @@ const make = Effect.gen(function* () { ? "Open or update T3 Code so its coordinated updater is available." : occupied ? reservation.phase === "running" && isAbandoned(reservation, registeredRenderer) - ? "A coordinated update outlived its interface. Check the affected machines before restarting T3 Code; the run cannot be safely reclaimed." + ? "A coordinated update outlived its interface. Check affected machines before restarting T3 Code and trying again." : "A T3 Code update or confirmation is already in progress." : !state.enabled ? (state.message ?? "Desktop updates are disabled.") @@ -163,7 +163,7 @@ const make = Effect.gen(function* () { return DesktopUpdateCommand.of({ inspect, reserve, - reserveForPreparedInstall, + reserveForRemoteUpdate, reserveForRenderer, finish, complete, @@ -204,11 +204,15 @@ const make = Effect.gen(function* () { ); const rendererId = yield* Ref.get(ready); const reservation = yield* reserveOwned(rendererId, "confirming"); - if (reservation === null) + if (reservation === null) { + const status = yield* inspect; return failure( request.requestId, - "A T3 Code update or confirmation is already in progress.", + status.state === "busy" && status.reason + ? status.reason + : "A T3 Code update or confirmation is already in progress.", ); + } const main = yield* windows.main; if (Option.isNone(main) || main.value.webContents.isDestroyed()) { yield* finish(reservation); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index 9479f3f4a819..2f51f319720a 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -30,7 +30,7 @@ export function runServicePreflight(input: { `The installed service launcher speaks protocol ${input.launcherProtocol}, but t3@${version} requires protocol ${SERVICE_LAUNCHER_PROTOCOL}.`, `On the server machine, install this exact CLI: \`curl -fsSL https://t3.codes/install.sh | T3CODE_VERSION=${version} sh\`.`, `Check that \`t3 --version\` prints ${version} and that \`which -a t3\` lists no older t3 (such as an npm or nvm install) first, then run \`~/.local/bin/t3 service install\` (the installer's default path).`, - "Updating the desktop app does not update this CLI, and `t3 service update` only repairs the service with whichever CLI runs it.", + "Run `t3 service install` through the exact CLI version you installed to repair the service launcher.", ].join(" "), }; } diff --git a/docs/user/updating.md b/docs/user/updating.md index c729b81b00d4..bcd129c391b4 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -48,13 +48,13 @@ Settings → About. Run `t3 update` with the app open, then review its confirmat the confirmation starts no update. Another update cannot start while that confirmation or run is active. The command reports when the run starts; follow its progress and restart result in the app. If the interface reloads during a run, another update stays blocked. Check the affected -machines before restarting T3 Code and retrying; a reload does not cancel their updates. +machines before restarting T3 Code and retrying. A reload does not cancel their updates. Desktop updates use the app's update track and confirmation. Exact versions, `--channel`, `--allow-downgrade`, and `--yes` are refused for this command; switch tracks in Settings → About. If the desktop app is closed or too old to coordinate the request, open or update it and try again. The deprecated `t3 service update` command follows the same routing. For a standalone CLI, it -selects the newest release on its channel; use `t3 service install` to install the invoking CLI version. +selects the newest release on its channel. Use `t3 service install` to install the invoking CLI version. Run `t3 update --check --json` to inspect cached update availability without changing the installation. Use the normal update action for a fresh check. From eae7e2889405a598f962f0c979bee42d4ecc3168 Mon Sep 17 00:00:00 2001 From: trahloc Date: Sun, 8 Nov 2026 17:59:18 +0100 Subject: [PATCH 08/14] fix(server): remove duplicate service install guidance --- apps/server/src/cloud/servicePreflight.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index 2f51f319720a..65136518a5f7 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -30,7 +30,6 @@ export function runServicePreflight(input: { `The installed service launcher speaks protocol ${input.launcherProtocol}, but t3@${version} requires protocol ${SERVICE_LAUNCHER_PROTOCOL}.`, `On the server machine, install this exact CLI: \`curl -fsSL https://t3.codes/install.sh | T3CODE_VERSION=${version} sh\`.`, `Check that \`t3 --version\` prints ${version} and that \`which -a t3\` lists no older t3 (such as an npm or nvm install) first, then run \`~/.local/bin/t3 service install\` (the installer's default path).`, - "Run `t3 service install` through the exact CLI version you installed to repair the service launcher.", ].join(" "), }; } From 755835677b072a0990b0a45f3db67be3cfbbf82c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 11:50:59 +0000 Subject: [PATCH 09/14] fix(desktop): coordinated update requests also check provider updates --- .../src/components/desktop/DesktopUpdateCoordinator.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx index 49d0293675b8..6c06fec093f0 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -1,6 +1,7 @@ import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import { useCheckProviderUpdates } from "../ProviderUpdatesAction"; import { downloadLocalUpdate, installLocalUpdate } from "../sidebar/SidebarUpdatePill"; import { useUpdateEverything } from "../sidebar/useUpdateEverything"; import { UpdateEverythingDialog } from "../sidebar/UpdateEverythingDialog"; @@ -22,6 +23,8 @@ export function DesktopUpdateCoordinator() { const localVersion = prepared && action !== "none" ? getDesktopUpdateDownloadedVersion(prepared) : null; const everything = useUpdateEverything(localVersion); + const checkProviderUpdates = useCheckProviderUpdates(); + const [checkingProviders, setCheckingProviders] = useState(false); const receive = useEffectEvent(async (incoming: DesktopUpdateCommandRequest) => { if (!command) return; @@ -29,6 +32,9 @@ export function DesktopUpdateCoordinator() { // The host serializes requests. A newer token supersedes a cancelled or expired confirmation. latestRequestId.current = incoming.requestId; setRequest(null); + // Machines that still cache an older latest version add their rows while the dialog is open. + setCheckingProviders(true); + void checkProviderUpdates({ quiet: true }).finally(() => setCheckingProviders(false)); let current = await bridge.getUpdateState(); if (latestRequestId.current !== incoming.requestId) return; if ( @@ -164,6 +170,7 @@ export function DesktopUpdateCoordinator() { providerCount={everything.providerCount} rows={everything.rows} restarts={everything.restarts} + checking={checkingProviders} onConfirm={() => void confirm()} /> ); From ec682ddc69faf3759769d3de7b5c8be39b6fd40e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:42:38 +0000 Subject: [PATCH 10/14] refactor: trim coordinated update tests and simplify track switch release Drop tests that only mirror copy or callback wiring, collapse the transport error table to one row per branch, release the track-switch reservation with try/finally, and restore the menu activation comment. AI-assisted; verified with the tests and tools listed above. --- .../src/window/DesktopApplicationMenu.test.ts | 64 ---------- .../src/window/DesktopApplicationMenu.ts | 1 + apps/server/src/cli/app.test.ts | 56 --------- .../settings/SettingsPanels.browser.test.tsx | 87 ------------- .../settings/UpdateTrackSwitchDialog.tsx | 116 +++++++++--------- .../SidebarUpdatePill.browser.test.tsx | 19 --- 6 files changed, 57 insertions(+), 286 deletions(-) delete mode 100644 apps/web/src/components/settings/SettingsPanels.browser.test.tsx diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ea228cdc9503..875b1890dabe 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -252,70 +252,6 @@ describe("DesktopApplicationMenu", () => { }), ); - it.effect("shows why the shared updater could not accept a menu request", () => - Effect.gen(function* () { - const template = yield* Deferred.make(); - const shown = yield* Deferred.make(); - yield* configureMenu( - yield* Deferred.make(), - template, - { platform: "darwin" }, - { - updates: { - check: () => - Effect.succeed({ - checked: true, - state: { - enabled: true, - status: "available", - channel: "latest", - currentVersion: "1.2.3", - availableVersion: "1.2.4", - downloadedVersion: null, - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - releaseNotes: [], - omittedReleaseCount: 0, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - }, - }), - }, - command: { - request: (request) => - Effect.succeed({ - version: 1, - requestId: request.requestId, - ok: false, - code: "renderer-unavailable", - message: "An update confirmation is already in progress.", - }), - }, - dialog: { - showMessageBox: (options) => - Deferred.succeed(shown, options).pipe( - Effect.as({ response: 0, checkboxChecked: false }), - ), - }, - }, - ); - const application = (yield* Deferred.await(template))[0]; - if (!Array.isArray(application?.submenu)) throw new Error("Expected app menu."); - const item = application.submenu.find((item) => item.label === "Check for Updates..."); - if (typeof item?.click !== "function") throw new Error("Expected update menu action."); - item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - assert.deepInclude(yield* Deferred.await(shown), { - type: "warning", - title: "Could not update T3 Code", - detail: "An update confirmation is already in progress.", - }); - }), - ); - it.effect("plans remote and provider updates even when this app is current", () => Effect.gen(function* () { const template = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 723a29610a53..05b60bbb9ed7 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -121,6 +121,7 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); const handleCheckForUpdatesMenuClick = Effect.gen(function* () { + // Opening a window before backend confirmation can strand it on failed auth bootstrap. const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 253188119c2b..3517d95c91ca 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -486,23 +486,16 @@ it.effect("classifies desktop update transport failures without exposing paths", Effect.gen(function* () { using send = vi.spyOn(appControl, "sendDesktopAppControlRequest"); const unavailable = Object.assign(new Error("secret /private/socket"), { code: "ENOENT" }); - const refused = Object.assign(new Error("secret /private/socket"), { code: "ECONNREFUSED" }); const unknown = Object.assign(new Error("secret /private/socket"), { code: "EACCES" }); const cases = [ [unavailable, "unavailable", "Open or update T3 Code"], - [refused, "unavailable", "Open or update T3 Code"], [unknown, "error", "Could not communicate"], [ new Error("The desktop app socket directory is not private or owned by this user."), "error", "socket directory is not private", ], - [new Error("The desktop app response is too large."), "error", "response is too large"], - [new Error("The desktop app response is not valid JSON."), "error", "not valid JSON"], - [new Error("The desktop app response is invalid."), "error", "response is invalid"], - [new Error("The desktop app response did not match this request."), "error", "did not match"], [new Error("The desktop app did not respond in time."), "error", "did not confirm"], - [new Error("The desktop app closed the connection."), "error", "did not confirm"], ] as const; for (const [error, state, reason] of cases) { send.mockRejectedValue(error); @@ -526,52 +519,3 @@ it.effect("classifies desktop update transport failures without exposing paths", } }).pipe(Effect.provide(NodeServices.layer)), ); - -it.effect( - "reports a non-private desktop socket directory in check mode without writing state", - () => - Effect.gen(function* () { - const platform = yield* HostProcess.Platform; - const userId = yield* HostProcess.UserId; - if (platform === "win32") return; - const root = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-private-check-")), - ); - const address = resolveDesktopAppControlAddress({ - stateDir: NodePath.join(root, "userdata"), - platform, - userId, - tempDir: NodeOS.tmpdir(), - joinPath: NodePath.join, - }); - if (address.directory === null) return; - try { - yield* Effect.promise(() => - NodeFSP.mkdir(address.directory!, { recursive: true, mode: 0o755 }), - ); - yield* Effect.promise(() => NodeFSP.chmod(address.directory!, 0o755)); - const check = yield* requestDesktopUpdate({ - baseDir: root, - currentVersion: "1", - check: true, - }); - if (typeof check === "string") - return yield* Effect.die("Expected an update-check response."); - expect(check.state).toBe("error"); - expect(check.reason).toContain("socket directory is not private"); - expect(yield* Effect.promise(() => NodeFSP.readdir(root))).toEqual([]); - } finally { - yield* Effect.promise(() => - NodeFSP.rm(address.directory!, { recursive: true, force: true }), - ); - yield* Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })); - } - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect("uses the generic update error for json without check", () => - Effect.gen(function* () { - const failure = yield* runCli(["update", "--json"]).pipe(Effect.flip); - expect(failure).toMatchObject({ _tag: "CliUpdateError", reason: "--json requires --check." }); - }), -); diff --git a/apps/web/src/components/settings/SettingsPanels.browser.test.tsx b/apps/web/src/components/settings/SettingsPanels.browser.test.tsx deleted file mode 100644 index 63f280c29b70..000000000000 --- a/apps/web/src/components/settings/SettingsPanels.browser.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { DesktopUpdateCommandRequest, DesktopUpdateState } from "@t3tools/contracts"; -import { expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; -import { render, stubGlobal } from "../../../test/render"; -import "../../index.css"; -import { AboutSettingsPanel } from "./SettingsPanels"; -import { DesktopUpdateCoordinator } from "../desktop/DesktopUpdateCoordinator"; - -const mocks = vi.hoisted(() => ({ state: null as DesktopUpdateState | null })); -vi.mock("../../state/desktopUpdate", async (original) => ({ - ...(await original()), - useDesktopUpdateState: () => mocks.state, -})); -vi.mock("../../env", async (original) => ({ - ...(await original()), - isElectron: true, -})); -vi.mock("../sidebar/useUpdateEverything", () => ({ - isHostedByThisApp: () => false, - useUpdateEverything: () => ({ - hasUpdates: true, - version: null, - providerCount: 1, - rows: [ - { - environmentId: null, - label: "Remote machine", - app: "server", - skipped: null, - providers: ["Codex"], - }, - ], - restarts: [], - }), -})); - -it("About checks the coordinated plan for remote/provider offers when the app is current", async () => { - const state: DesktopUpdateState = { - enabled: true, - status: "up-to-date", - channel: "latest", - currentVersion: "0.1.7", - availableVersion: null, - downloadedVersion: null, - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - releaseNotes: [], - omittedReleaseCount: 0, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - }; - mocks.state = state; - let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; - using _bridge = stubGlobal("desktopBridge", { - getUpdateState: async () => state, - checkForUpdate: async () => ({ checked: true, state }), - getLocalEnvironmentBootstraps: () => [], - updateCommand: { - setReady: async () => undefined, - complete: async () => true, - request: async () => { - listener?.({ version: 1, requestId: "about", type: "update" }); - return { version: 1, requestId: "about", ok: true, status: "cancelled" }; - }, - onRequest: (callback: typeof listener) => { - listener = callback; - return () => { - listener = undefined; - }; - }, - }, - }); - await using screen = await render( - <> - - - , - ); - await userEvent.click(screen.getByRole("button", { name: "Check for Updates", exact: true })); - await expect.element(page.getByRole("alertdialog")).toBeVisible(); - await expect.element(page.getByText("Remote machine", { exact: true })).toBeVisible(); - await expect.element(page.getByText("Codex", { exact: true })).toBeVisible(); -}); diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index 54d1cc4d85d3..6b604eca19fa 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -107,68 +107,64 @@ function SwitchPlan({ ); return; } - await Promise.resolve() - .then(async () => { - // Stay open while the feed is checked; the run's own progress takes over after. - setChecking(true); - const fail = (description: string) => - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not switch to ${label}`, - description, - }), - ); - const prepared = await prepareLocalTrackSwitch(bridge, { - channel: release.channel, - version: release.version, - reinstall: switchesLocal, - }); - onClose(); - if (!prepared.ok) { - fail(prepared.message); - return; - } - const { local } = prepared; - const result = await settlePromise(() => - everything.run( - local === null - ? {} - : { - ...(local.status === "available" - ? { - downloadLocal: (reportError) => - downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), - } - : {}), - // Install only a build the database check covers, pinned so nothing replaces it. - installLocal: async (reportError) => { - const { downloadedVersion } = await bridge.getUpdateState(); - if ( - downloadedVersion === null || - !acceptsTrackSwitchRelease( - downloadedVersion, - release.channel, - release.version, - ) - ) { - reportError( - "Could not install update", - `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, - ); - return false; + try { + // Stay open while the feed is checked; the run's own progress takes over after. + setChecking(true); + const fail = (description: string) => + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not switch to ${label}`, + description, + }), + ); + const prepared = await prepareLocalTrackSwitch(bridge, { + channel: release.channel, + version: release.version, + reinstall: switchesLocal, + }); + onClose(); + if (!prepared.ok) { + fail(prepared.message); + return; + } + const { local } = prepared; + const result = await settlePromise(() => + everything.run( + local === null + ? {} + : { + ...(local.status === "available" + ? { + downloadLocal: (reportError) => + downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), } - return installLocalUpdate(bridge, reportError, downloadedVersion); - }, + : {}), + // Install only a build the database check covers, pinned so nothing replaces it. + installLocal: async (reportError) => { + const { downloadedVersion } = await bridge.getUpdateState(); + if ( + downloadedVersion === null || + !acceptsTrackSwitchRelease(downloadedVersion, release.channel, release.version) + ) { + reportError( + "Could not install update", + `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, + ); + return false; + } + return installLocalUpdate(bridge, reportError, downloadedVersion); }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - fail(error instanceof Error ? error.message : "An unexpected error occurred."); - } - }) - .finally(() => bridge.updateCommand.finish(reservation)); + }, + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + fail(error instanceof Error ? error.message : "An unexpected error occurred."); + } + } finally { + await bridge.updateCommand.finish(reservation); + } }; return ( diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 46099eca314c..1798d2bed927 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -146,25 +146,6 @@ describe("SidebarUpdatePill release notes", () => { }); describe("SidebarUpdatePill update everything", () => { - it("uses the shared check when no local or remote offers are known", async () => { - setup({ ...nightlyUpdate([]), status: "up-to-date", availableVersion: null }); - const request = vi.fn(async () => ({ - version: 1, - requestId: "sidebar", - ok: true, - status: "up-to-date", - })); - const localCheck = vi.fn(async () => ({ checked: true, state: mocks.update })); - using _bridge = stubGlobal("desktopBridge", { - updateCommand: { request }, - checkForUpdate: localCheck, - }); - await using screen = await render(); - await userEvent.click(screen.getByRole("button", { name: "Check for updates", exact: true })); - await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); - expect(localCheck).not.toHaveBeenCalled(); - }); - it("does not refocus the update button after a pointer cancel", async () => { setup(null, { hasUpdates: true }); using _bridge = coordinateUpdates(); From 3ea389b073f66cf37eb010ffaf1195ea2361e92d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:46:02 +0000 Subject: [PATCH 11/14] fix(web): keep track switch release compilable by React Compiler AI-assisted; verified with the tests and tools listed above. --- .../src/components/settings/UpdateTrackSwitchDialog.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index 6b604eca19fa..0fd9874e764b 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -107,7 +107,7 @@ function SwitchPlan({ ); return; } - try { + const switchTrack = async () => { // Stay open while the feed is checked; the run's own progress takes over after. setChecking(true); const fail = (description: string) => @@ -162,9 +162,8 @@ function SwitchPlan({ const error = squashAtomCommandFailure(result); fail(error instanceof Error ? error.message : "An unexpected error occurred."); } - } finally { - await bridge.updateCommand.finish(reservation); - } + }; + await switchTrack().finally(() => bridge.updateCommand.finish(reservation)); }; return ( From df0cce4a2e20218e2ad755e6f10ac4e987dde918 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:13:11 +0000 Subject: [PATCH 12/14] fix(web): coordinated update requests offer provider updates found by the refresh Wait for the provider refresh before deciding a request has nothing to offer, and decide after a render so it reads the refreshed offers instead of the snapshot captured when the request arrived. AI-assisted; verified with the tests and tools listed above. --- .../DesktopUpdateCoordinator.browser.test.tsx | 66 +++++++++++++++++-- .../desktop/DesktopUpdateCoordinator.tsx | 58 ++++++++++------ 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx index 61df47c3d774..a9bca62699bb 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx @@ -10,10 +10,17 @@ import "../../index.css"; import { DesktopUpdateCoordinator } from "./DesktopUpdateCoordinator"; import { ToastProvider } from "../ui/toast"; -const mocks = vi.hoisted(() => ({ run: vi.fn(async () => undefined) })); +const mocks = vi.hoisted(() => ({ + run: vi.fn(async () => undefined), + hasUpdates: true, + checkProviders: vi.fn(async () => undefined), +})); +vi.mock("../ProviderUpdatesAction", () => ({ + useCheckProviderUpdates: () => mocks.checkProviders, +})); vi.mock("../sidebar/useUpdateEverything", () => ({ useUpdateEverything: () => ({ - hasUpdates: true, + hasUpdates: mocks.hasUpdates, version: "0.1.8", providerCount: 0, rows: [ @@ -43,8 +50,57 @@ const state: DesktopUpdateState = { canRetry: false, }; -it("uses one native confirmation and cancels without starting the coordinated runner", async () => { +function setup() { mocks.run.mockClear(); + mocks.hasUpdates = true; + mocks.checkProviders.mockImplementation(async () => undefined); +} + +it("offers provider updates found by the refresh while this app is current", async () => { + setup(); + mocks.hasUpdates = false; + mocks.checkProviders.mockImplementation(async () => { + mocks.hasUpdates = true; + }); + const current: DesktopUpdateState = { ...state, status: "up-to-date", availableVersion: null }; + let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; + const complete = vi.fn(async () => true); + using _bridge = stubGlobal("desktopBridge", { + getUpdateState: async () => current, + checkForUpdate: async () => ({ checked: true, state: current }), + updateCommand: { + setReady: async () => undefined, + complete, + finish: async () => undefined, + onRequest: (callback: typeof listener) => { + listener = callback; + return () => { + listener = undefined; + }; + }, + }, + }); + await using _screen = await render(); + await act(async () => listener?.({ version: 1, requestId: "cli", type: "update" })); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + expect(complete).not.toHaveBeenCalled(); + + mocks.hasUpdates = false; + mocks.checkProviders.mockImplementation(async () => undefined); + await act(async () => listener?.({ version: 1, requestId: "about", type: "update" })); + await vi.waitFor(() => + expect(complete).toHaveBeenCalledWith({ + version: 1, + requestId: "about", + ok: true, + status: "up-to-date", + }), + ); + await expect.element(page.getByRole("alertdialog")).not.toBeInTheDocument(); +}); + +it("uses one native confirmation and cancels without starting the coordinated runner", async () => { + setup(); let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; const complete = vi.fn(async () => true); const finish = vi.fn(async () => undefined); @@ -89,7 +145,7 @@ it("uses one native confirmation and cancels without starting the coordinated ru }); it("replaces a disconnected confirmation and ignores its late preparation", async () => { - mocks.run.mockClear(); + setup(); let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; let releaseOld: ((value: DesktopUpdateState) => void) | undefined; const oldState = new Promise((resolve) => { @@ -139,7 +195,7 @@ it("replaces a disconnected confirmation and ignores its late preparation", asyn }); it("explains an expired confirmation without starting an update", async () => { - mocks.run.mockClear(); + setup(); let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; using _bridge = stubGlobal("desktopBridge", { getUpdateState: async () => state, diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx index 6c06fec093f0..59cf95faa985 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -34,7 +34,9 @@ export function DesktopUpdateCoordinator() { setRequest(null); // Machines that still cache an older latest version add their rows while the dialog is open. setCheckingProviders(true); - void checkProviderUpdates({ quiet: true }).finally(() => setCheckingProviders(false)); + const providerCheck = checkProviderUpdates({ quiet: true }).finally(() => + setCheckingProviders(false), + ); let current = await bridge.getUpdateState(); if (latestRequestId.current !== incoming.requestId) return; if ( @@ -55,29 +57,43 @@ export function DesktopUpdateCoordinator() { }); return; } - const localAction = current.enabled ? resolveDesktopUpdateButtonAction(current) : "none"; - if (localAction === "none" && !everything.hasUpdates) { - const reason = !current.enabled - ? (current.message ?? "Desktop updates are disabled.") - : current.status === "error" - ? current.message - : null; - await command.complete( - reason - ? { - version: 1, - requestId: incoming.requestId, - ok: false, - code: "internal-error", - message: reason, - } - : { version: 1, requestId: incoming.requestId, ok: true, status: "up-to-date" }, - ); - return; + // With nothing local to offer, only the refreshed provider state can decide; see `nothingOffered`. + if (!current.enabled || resolveDesktopUpdateButtonAction(current) === "none") { + await providerCheck; + if (latestRequestId.current !== incoming.requestId) return; } setPrepared(current); setRequest(incoming); }); + const nothingOffered = + request !== null && prepared !== null && action === "none" && !everything.hasUpdates; + const replyNothingOffered = useEffectEvent(async () => { + if (!request || !prepared || !command) return; + const answered = request.requestId; + const reason = !prepared.enabled + ? (prepared.message ?? "Desktop updates are disabled.") + : prepared.status === "error" + ? prepared.message + : null; + await command.complete( + reason + ? { + version: 1, + requestId: request.requestId, + ok: false, + code: "internal-error", + message: reason, + } + : { version: 1, requestId: request.requestId, ok: true, status: "up-to-date" }, + ); + setRequest((current) => (current?.requestId === answered ? null : current)); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- answers the host only after a render reads the refreshed provider offers + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the host has the reply. + if (nothingOffered) void replyNothingOffered(); + }, [nothingOffered]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- receives native update intents independently of the local backend connection useEffect(() => { if (!command) return; @@ -159,7 +175,7 @@ export function DesktopUpdateCoordinator() { ); } }; - if (!request) return null; + if (!request || nothingOffered) return null; return ( Date: Wed, 11 Nov 2026 04:27:29 +0000 Subject: [PATCH 13/14] fix: decide provider offers from the refresh and keep service update as a repair Coordinated update requests now decide from the providers the refresh returns, so an offer the debounced provider-status push has not delivered yet is still confirmed instead of reported as up to date. `t3 service update` goes back to repairing the service with the invoking CLI; the coordination fix does not need to change it. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cli/app.test.ts | 4 +- apps/server/src/cli/service.test.ts | 66 ++++++++++--------- apps/server/src/cli/service.ts | 44 +++++-------- apps/server/src/cli/update.ts | 2 +- apps/server/src/cloud/servicePreflight.ts | 1 + .../src/components/ProviderUpdatesAction.tsx | 26 ++++++-- .../DesktopUpdateCoordinator.browser.test.tsx | 13 ++-- .../desktop/DesktopUpdateCoordinator.tsx | 56 +++++++--------- docs/operations/release.md | 2 +- docs/user/updating.md | 2 - 10 files changed, 104 insertions(+), 112 deletions(-) diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 3517d95c91ca..6721d0c0e449 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -455,8 +455,6 @@ it.effect("routes a legacy desktop shim's update and read-only check through des assert.deepStrictEqual(yield* Effect.promise(() => NodeFSP.readdir(root)), []); yield* runCli(["update", "--base-dir", root]); assert.equal(desktop.received[1]?.type, "update"); - yield* runCli(["service", "update", "--base-dir", root]); - assert.equal(desktop.received[2]?.type, "update"); for (const options of [ ["--yes"], ["--channel", "stable"], @@ -466,7 +464,7 @@ it.effect("routes a legacy desktop shim's update and read-only check through des const refused = yield* runCli(["update", ...options, "--base-dir", root]).pipe(Effect.exit); assert.isTrue(Exit.isFailure(refused)); } - assert.equal(desktop.received.length, 3); + assert.equal(desktop.received.length, 2); unsupported = true; const oldDesktop = yield* runCli(["update", "--base-dir", root]).pipe(Effect.flip); expect(oldDesktop).toMatchObject({ diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 903f2e13f9f7..900c41e66174 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -60,7 +60,7 @@ function makeTestService( it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { it.effect.each( - (["install"] as const).flatMap((command) => [ + (["install", "update"] as const).flatMap((command) => [ { command, label: "a downgrade", @@ -178,39 +178,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands ); // regression: #13239 a stale nvm-installed CLI reported "already using" its own version - it.effect.each(["install"] as const)("%s names the CLI that found nothing to change", (command) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); - const { service, installOptions } = makeTestService(status); - vi.spyOn(BootService, "layer").mockReturnValue( - Layer.succeed(BootService.BootService, service), - ); - const node = "/home/me/.nvm/versions/node/v26.8.2/bin/node"; - const script = "/home/me/.nvm/versions/node/v26.8.2/lib/node_modules/t3/dist/bin.mjs"; + it.effect.each(["install", "update"] as const)( + "%s names the CLI that found nothing to change", + (command) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions } = makeTestService(status); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + const node = "/home/me/.nvm/versions/node/v26.8.2/bin/node"; + const script = "/home/me/.nvm/versions/node/v26.8.2/lib/node_modules/t3/dist/bin.mjs"; - const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ - command, - "--base-dir", - baseDir, - ]).pipe( - Effect.andThen(TestConsole.logLines), - Effect.map((lines) => lines.join("\n")), - Effect.provideService(HostProcess.Environment, {}), - Effect.provideService(HostProcess.IsExecutable, false), - Effect.provideService(HostProcess.ExecutablePath, node), - Effect.provideService(HostProcess.Arguments, [node, script, "service", command]), - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), - Layer.fresh(TestConsole.layer), + const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + command, + "--base-dir", + baseDir, + ]).pipe( + Effect.andThen(TestConsole.logLines), + Effect.map((lines) => lines.join("\n")), + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, node), + Effect.provideService(HostProcess.Arguments, [node, script, "service", command]), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), ), - ), - ); - expect(output).toContain(`t3@${packageJson.version}`); - expect(output).toContain(script); - expect(installOptions).toEqual([]); - }), + ); + expect(output).toContain(`t3@${packageJson.version}`); + expect(output).toContain(script); + expect(installOptions).toEqual([]); + }), ); it.effect.each([ diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 421f1c989b49..ce43154116b8 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -1,4 +1,3 @@ -import { runUpdateCommand } from "./update.ts"; import * as NodeUtil from "node:util"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -6,7 +5,6 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient } from "effect/http"; @@ -240,33 +238,25 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ), ); -const serviceUpdateFlags = { - ...projectLocationFlags, - allowDowngrade: Flag.Boolean("allow-downgrade").pipe( - Flag.withDescription( - "Allow updating to the newest channel release even if it is older than the installed version.", - ), - Flag.withDefault(false), - ), -}; - -const serviceUpdateCommand = Command.make("update", serviceUpdateFlags).pipe( - Command.withDescription("Deprecated alias for t3 update."), +const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( + Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."), Command.unlisted, Command.withHandler((flags) => - Console.log( - "`t3 service update` is deprecated; it selects the newest channel release via `t3 update`. To install the invoking CLI version, use `t3 service install`.", - ).pipe( - Effect.andThen( - runUpdateCommand({ - ...flags, - channel: Option.none(), - version: Option.none(), - check: false, - json: false, - yes: false, - }), - ), + runServiceCommand( + flags, + Effect.gen(function* () { + yield* Console.log( + "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.", + ); + const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); + if (!result.changed) { + yield* Console.log( + `T3 Code service is already using t3@${packageJson.version} (${yield* resolveEntryPath}).`, + ); + return; + } + yield* Console.log(formatServiceChange(result)); + }), ), ), ); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 79c7648eb920..3041dc4ff030 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -282,7 +282,7 @@ const versionArgument = Argument.String("version").pipe( Argument.optional, ); -export const runUpdateCommand = Effect.fn("cli.update.command")(function* (flags: { +const runUpdateCommand = Effect.fn("cli.update.command")(function* (flags: { readonly baseDir: Option.Option; readonly check: boolean; readonly json: boolean; diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index 65136518a5f7..9479f3f4a819 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -30,6 +30,7 @@ export function runServicePreflight(input: { `The installed service launcher speaks protocol ${input.launcherProtocol}, but t3@${version} requires protocol ${SERVICE_LAUNCHER_PROTOCOL}.`, `On the server machine, install this exact CLI: \`curl -fsSL https://t3.codes/install.sh | T3CODE_VERSION=${version} sh\`.`, `Check that \`t3 --version\` prints ${version} and that \`which -a t3\` lists no older t3 (such as an npm or nvm install) first, then run \`~/.local/bin/t3 service install\` (the installer's default path).`, + "Updating the desktop app does not update this CLI, and `t3 service update` only repairs the service with whichever CLI runs it.", ].join(" "), }; } diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 4834487d55e6..8929c4ca9177 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -28,10 +28,12 @@ export function useCheckProviderUpdates() { const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); - const inFlight = useRef | null>(null); + const inFlight = useRef | null>(null); + // Resolves whether the refreshed providers offer a one-click update. The response carries them + // ahead of the debounced provider-status push, so callers can decide before that lands. const check = async (quiet: boolean) => { - const failures = await Promise.all( + const results = await Promise.all( environments .filter( (environment) => @@ -40,13 +42,24 @@ export function useCheckProviderUpdates() { ) .map(async ({ environmentId, label }) => { const result = await refreshProviders({ environmentId, input: { refreshModels: true } }); - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return []; + if (result._tag === "Success") { + const { providers } = result.value; + const offers = collectProviderUpdateCandidates(providers).some((candidate) => + canOneClickUpdateProviderCandidate(candidate, providers), + ); + return { offers, failure: null }; + } + if (isAtomCommandInterrupted(result)) return { offers: false, failure: null }; const error = squashAtomCommandFailure(result); - return [`${label}: ${error instanceof Error ? error.message : "Refresh failed."}`]; + return { + offers: false, + failure: `${label}: ${error instanceof Error ? error.message : "Refresh failed."}`, + }; }), ); - const lines = failures.flat(); - if (quiet || lines.length === 0) return; + const offers = results.some((result) => result.offers); + const lines = results.flatMap((result) => (result.failure === null ? [] : [result.failure])); + if (quiet || lines.length === 0) return offers; toastManager.add( stackedThreadToast({ type: "error", @@ -54,6 +67,7 @@ export function useCheckProviderUpdates() { description: {lines.join("\n")}, }), ); + return offers; }; // A repeat click must not start a second model rediscovery while one is running. diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx index a9bca62699bb..a1f97075cf46 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx @@ -13,7 +13,7 @@ import { ToastProvider } from "../ui/toast"; const mocks = vi.hoisted(() => ({ run: vi.fn(async () => undefined), hasUpdates: true, - checkProviders: vi.fn(async () => undefined), + checkProviders: vi.fn(async () => false), })); vi.mock("../ProviderUpdatesAction", () => ({ useCheckProviderUpdates: () => mocks.checkProviders, @@ -53,15 +53,14 @@ const state: DesktopUpdateState = { function setup() { mocks.run.mockClear(); mocks.hasUpdates = true; - mocks.checkProviders.mockImplementation(async () => undefined); + mocks.checkProviders.mockImplementation(async () => false); } it("offers provider updates found by the refresh while this app is current", async () => { setup(); + // The refresh reports the offer before the debounced provider-status push reaches the rows. mocks.hasUpdates = false; - mocks.checkProviders.mockImplementation(async () => { - mocks.hasUpdates = true; - }); + mocks.checkProviders.mockImplementation(async () => true); const current: DesktopUpdateState = { ...state, status: "up-to-date", availableVersion: null }; let listener: ((request: DesktopUpdateCommandRequest) => void) | undefined; const complete = vi.fn(async () => true); @@ -85,8 +84,8 @@ it("offers provider updates found by the refresh while this app is current", asy await expect.element(page.getByRole("alertdialog")).toBeVisible(); expect(complete).not.toHaveBeenCalled(); - mocks.hasUpdates = false; - mocks.checkProviders.mockImplementation(async () => undefined); + await userEvent.click(page.getByRole("button", { name: "Cancel", exact: true })); + mocks.checkProviders.mockImplementation(async () => false); await act(async () => listener?.({ version: 1, requestId: "about", type: "update" })); await vi.waitFor(() => expect(complete).toHaveBeenCalledWith({ diff --git a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx index 59cf95faa985..29ad01584366 100644 --- a/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -57,43 +57,32 @@ export function DesktopUpdateCoordinator() { }); return; } - // With nothing local to offer, only the refreshed provider state can decide; see `nothingOffered`. if (!current.enabled || resolveDesktopUpdateButtonAction(current) === "none") { - await providerCheck; + const providerOffers = await providerCheck; if (latestRequestId.current !== incoming.requestId) return; + if (!providerOffers && !everything.hasUpdates) { + const reason = !current.enabled + ? (current.message ?? "Desktop updates are disabled.") + : current.status === "error" + ? current.message + : null; + await command.complete( + reason + ? { + version: 1, + requestId: incoming.requestId, + ok: false, + code: "internal-error", + message: reason, + } + : { version: 1, requestId: incoming.requestId, ok: true, status: "up-to-date" }, + ); + return; + } } setPrepared(current); setRequest(incoming); }); - const nothingOffered = - request !== null && prepared !== null && action === "none" && !everything.hasUpdates; - const replyNothingOffered = useEffectEvent(async () => { - if (!request || !prepared || !command) return; - const answered = request.requestId; - const reason = !prepared.enabled - ? (prepared.message ?? "Desktop updates are disabled.") - : prepared.status === "error" - ? prepared.message - : null; - await command.complete( - reason - ? { - version: 1, - requestId: request.requestId, - ok: false, - code: "internal-error", - message: reason, - } - : { version: 1, requestId: request.requestId, ok: true, status: "up-to-date" }, - ); - setRequest((current) => (current?.requestId === answered ? null : current)); - }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- answers the host only after a render reads the refreshed provider offers - useEffect(() => { - // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the host has the reply. - if (nothingOffered) void replyNothingOffered(); - }, [nothingOffered]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- receives native update intents independently of the local backend connection useEffect(() => { if (!command) return; @@ -175,7 +164,7 @@ export function DesktopUpdateCoordinator() { ); } }; - if (!request || nothingOffered) return null; + if (!request) return null; return ( void confirm()} /> ); diff --git a/docs/operations/release.md b/docs/operations/release.md index bc81706dba36..be74b2ad2982 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -460,7 +460,7 @@ connect the new client to a server on the previous version and verify that the u reconnects to the matching server. When the release adds database migrations, verify that the remote update applies them and reconnects. A failed trial must restore the database snapshot and restart the previous server. If the installed launcher does not support the target protocol, -verify that the update stops before restart and run `npx t3@ service install` once on the +verify that the update stops before restart and run `npx t3@ service update` once on the server machine. Also test the manual or desktop-managed guidance when those environments are available. diff --git a/docs/user/updating.md b/docs/user/updating.md index bcd129c391b4..3900eecbdddc 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -53,8 +53,6 @@ machines before restarting T3 Code and retrying. A reload does not cancel their Desktop updates use the app's update track and confirmation. Exact versions, `--channel`, `--allow-downgrade`, and `--yes` are refused for this command; switch tracks in Settings → About. If the desktop app is closed or too old to coordinate the request, open or update it and try again. -The deprecated `t3 service update` command follows the same routing. For a standalone CLI, it -selects the newest release on its channel. Use `t3 service install` to install the invoking CLI version. Run `t3 update --check --json` to inspect cached update availability without changing the installation. Use the normal update action for a fresh check. From a6c27f9afa3b1ea2abf5a5e52f6fc10e6af5f4b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:31:50 +0000 Subject: [PATCH 14/14] test(web): refresh mock returns the provider list the contract promises AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/ProviderUpdatesAction.browser.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx index a00914ad9dcd..734c3d56999a 100644 --- a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx @@ -54,7 +54,7 @@ vi.mock(import("~/state/use-atom-command"), () => ({ return AsyncResult.success(undefined); } state.checks.push({ environmentId, input }); - return state.checkResult ?? AsyncResult.success(undefined); + return state.checkResult ?? AsyncResult.success({ providers: [] }); }) as never, }));