diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 6cb00baf897d..2a00949ea353 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"; @@ -70,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(); @@ -84,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(() => { @@ -92,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 +136,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 +180,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( @@ -213,6 +238,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, {}), @@ -248,13 +274,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); @@ -264,5 +293,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 e3b6c49e3ab8..7dcc9a4b417b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -174,6 +174,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; @@ -210,6 +214,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; } @@ -274,10 +279,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")); diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index 2bf1e716a99a..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"; @@ -63,8 +63,8 @@ function startOkServer(target: ReturnType, userId: number | u }); } -function exchange(address: string, payload: DesktopAppActivationRequest) { - return new Promise((resolve, reject) => { +function exchange(address: string, payload: unknown) { + return new Promise((resolve, reject) => { const socket = NodeNet.createConnection(address); socket.setEncoding("utf8"); let buffer = ""; @@ -75,12 +75,53 @@ function exchange(address: string, payload: DesktopAppActivationRequest) { 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); }); }); } 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 f8bd8b5f91de..5465f6bf8c70 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -51,6 +51,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. @@ -72,6 +73,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 6a1b1c62e747..35a36253b8ee 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -32,6 +32,11 @@ import { getUpdateState, installUpdate, setUpdateChannel, + updateCommandReady, + updateCommandComplete, + updateCommandRun, + updateCommandReserve, + updateCommandFinish, } from "./methods/updates.ts"; import { getAppBranding, @@ -162,6 +167,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 aebbc1c24f8c..99dd102bb2ee 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -132,3 +132,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..2db9ad96fc56 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).reserveForRenderer; + }), +}); +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 e3971d7c74ea..85d0572049ab 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 2dbc4eeda3ae..85a1e684e620 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -310,6 +310,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..71cd2f34a2be 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,14 @@ 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(), + DesktopUpdateCommand.layer.pipe(Layer.provideMerge(harness.layer)), + ), + ), + ); } function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateStatusReport[] { @@ -664,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 560e2e78d4bb..4f5792fc5418 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,23 @@ export const listen: Effect.Effect< const handleRequest = (request: DesktopTelemetryRequestDesktopUpdate): Effect.Effect => Effect.scoped( Effect.gen(function* () { + const reservation = yield* command.reserveForRemoteUpdate; + if (reservation === null) { + const prepared = yield* Ref.get(preparedUpdateRef); + yield* publishReport( + yield* updates.getState, + { + outcome: "failed", + 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, + ); + 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 +504,22 @@ export const listen: Effect.Effect< } return; } - const result = yield* updates.installPrepared(claim.prepared.downloadedVersion); + if (yield* updates.isInstallActive) { + yield* logInfo("remote update commit joining an in-progress install", { + requestId: commit.requestId, + }); + return; + } + const reservation = yield* command.reserveForRemoteUpdate; + 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..ad67e6b0c05f --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdateCommand.test.ts @@ -0,0 +1,328 @@ +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 Ref from "effect/Ref"; +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, + isActionActive = Effect.succeed(false), +) => + DesktopUpdateCommand.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(DesktopUpdates.DesktopUpdates, { + getState: Effect.succeed(state), + isActionActive, + }), + 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); + 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; + assert.isNotNull(second); + 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"); + assert.isNull(yield* command.reserveForRenderer); + yield* command.finish(rendererReservation!); + 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(() => { + 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( + "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; + 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); + 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); + 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); + 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)), + ), + ); + }), +); + +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 new file mode 100644 index 000000000000..5f14059187ac --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdateCommand.ts @@ -0,0 +1,256 @@ +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 reserveForRemoteUpdate: Effect.Effect; + readonly reserveForRenderer: Effect.Effect; + readonly finish: (reservation: string) => Effect.Effect; + } +>()("@t3tools/desktop/updates/DesktopUpdateCommand") {} + +interface PendingCommand { + readonly requestId: string; + readonly callerId: string; + readonly reply: Deferred.Deferred; +} + +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, + 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, 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) => + 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") { + 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, + }); + return true; + }); + const reserveOwned = Effect.fn("DesktopUpdateCommand.reserve")(function* ( + rendererId: string | null, + phase: "confirming" | "running" = "running", + allowCheck = false, + ) { + if ((yield* updates.isActionActive) && !(allowCheck && (yield* updates.isCheckActive))) + 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, rendererId, phase, next: current.next + 1 }] as const; + }); + }); + const reserve = reserveOwned(null); + const reserveForRemoteUpdate = reserveOwned(null, "running", true); + 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 = + registeredRenderer !== null && Option.isSome(main) && !main.value.webContents.isDestroyed(); + const reservation = yield* Ref.get(lock); + const occupied = reservation.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." + : occupied + ? reservation.phase === "running" && isAbandoned(reservation, registeredRenderer) + ? "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.") + : 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, + reserveForRemoteUpdate, + reserveForRenderer, + 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) { + 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; + 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 rendererId = yield* Ref.get(ready); + const reservation = yield* reserveOwned(rendererId, "confirming"); + if (reservation === null) { + const status = yield* inspect; + return failure( + request.requestId, + 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); + 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 + : finishConfirmation(reservation), + ), + Effect.ensuring(clearPending(reservation)), + Effect.onInterrupt(() => finishConfirmation(reservation)), + ); + }), + }); +}); +export const layer = Layer.effect(DesktopUpdateCommand, make); 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 612ec441ba99..875b1890dabe 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"; @@ -56,16 +57,17 @@ 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), + isCheckActive: Effect.succeed(false), isInstallActive: Effect.succeed(false), subscribe: Effect.die("unexpected subscribe"), emitState: Effect.void, @@ -76,7 +78,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, { @@ -113,6 +115,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; @@ -122,8 +129,33 @@ const configureMenu = ( DesktopApplicationMenu.layer.pipe( Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)), Layer.provideMerge(layerDesktopWindow(selectedAction)), - Layer.provideMerge(layerDesktopUpdates), - Layer.provideMerge(layerElectronDialog), + Layer.provideMerge( + Layer.succeed(DesktopUpdates.DesktopUpdates, { ...desktopUpdates, ...overrides.updates }), + ), + 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, + requestId: request.requestId, + ok: true, + status: "cancelled", + }), + ...overrides.command, + }), + ), + Layer.provideMerge( + Layer.succeed(ElectronDialog.ElectronDialog, { ...electronDialog, ...overrides.dialog }), + ), Layer.provideMerge(layerElectronApp), Layer.provideMerge( DesktopEnvironment.layer({ ...environmentInput, ...environment }).pipe( @@ -135,6 +167,152 @@ 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("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 44d2c234f835..05b60bbb9ed7 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -1,7 +1,8 @@ +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"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Electron from "electron"; @@ -10,7 +11,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()( @@ -33,12 +33,11 @@ export class DesktopApplicationMenu extends Context.Service< >()("@t3tools/desktop/window/DesktopApplicationMenu") {} 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* ( @@ -65,47 +64,63 @@ 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 === "up-to-date") { + 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: "info", - title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + 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"], }); - } else if (updateState.status === "error") { + return; + } + const response = yield* command.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")); +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; diff --git a/apps/server/src/binMain.ts b/apps/server/src/binMain.ts index 0dabd7387344..3eb75e2989d3 100644 --- a/apps/server/src/binMain.ts +++ b/apps/server/src/binMain.ts @@ -31,7 +31,14 @@ export async function runCliCommand() { const { runNativeSpeechWorker } = await import("./speech/native.ts"); await runNativeSpeechWorker(mode, process.argv[4]); } else { - enableCliCompileCache(); + 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 eecdd7fbe501..6721d0c0e449 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,8 +21,11 @@ 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"; +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"; @@ -71,12 +78,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 +101,7 @@ async function startFakeDesktop(input: { }); } - const received: DesktopAppActivationRequest[] = []; + const received: Request[] = []; const server = NodeNet.createServer((socket) => { socket.setEncoding("utf8"); let buffer = ""; @@ -100,7 +109,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) @@ -134,12 +143,17 @@ async function startFakeDesktop(input: { } 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()), ); }); @@ -391,3 +405,115 @@ 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")); + let unsupported = false; + const desktop = yield* Effect.promise(() => + startFakeDesktop({ + baseDir: root, + platform, + userId, + reply: (request) => + unsupported + ? { + version: 1, + requestId: request.requestId, + ok: false, + code: "invalid-request", + message: "The desktop app request is invalid.", + } + : { + 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"); + 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, 2); + 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()); + yield* Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })); + } + }), +); + +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 unknown = Object.assign(new Error("secret /private/socket"), { code: "EACCES" }); + const cases = [ + [unavailable, "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 did not respond in time."), "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)), +); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index e4182945215c..0671238befc7 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,14 @@ 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); +const isDesktopAppControlResponse = ( + response: unknown, + request: DesktopAppControlRequest, +): response is DesktopAppControlResponse => + request.type === "open-workspace" + ? isDesktopAppActivationResponse(response) + : isDesktopUpdateCommandResponse(response); export class DesktopAppSshUnsupportedError extends Schema.TaggedError()( "DesktopAppSshUnsupportedError", @@ -82,13 +93,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 +120,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 +161,7 @@ async function sendDesktopAppActivationRequest(input: { }); return; } - if (!isDesktopAppActivationResponse(parsed)) { + if (!isDesktopAppControlResponse(parsed, input.request)) { finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); return; } @@ -174,7 +185,7 @@ async function sendDesktopAppActivationRequest(input: { clearTimeout(timeout); socket.destroy(); resolve( - sendDesktopAppActivationRequest({ + sendDesktopAppControlRequest({ address: input.fallbackAddress, userId: input.userId, request: input.request, @@ -240,7 +251,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..136b55bd378e --- /dev/null +++ b/apps/server/src/cli/desktopUpdate.ts @@ -0,0 +1,118 @@ +// @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; + } +} +const safeTransportMessages = new Set([ + "The desktop app socket directory is not private or owned by this user.", + "The desktop app response is too large.", + "The desktop app response is not valid JSON.", + "The desktop app response is invalid.", + "The desktop app response did not match this request.", +]); + +function transportFailure(error: unknown): { state: "unavailable" | "error"; reason: string } { + if (typeof error === "object" && error !== null && "code" in error) { + if (error.code === "ENOENT" || error.code === "ECONNREFUSED") { + return { + state: "unavailable", + reason: + "Open or update T3 Code, then run t3 update again. No standalone update was attempted.", + }; + } + return { + state: "error", + reason: + "Could not communicate with T3 Code. Check the desktop app and try again. No standalone update was attempted.", + }; + } + const message = error instanceof Error ? error.message : ""; + if ( + message === "The desktop app did not respond in time." || + message === "The desktop app closed the connection." + ) { + return { + state: "error", + reason: + "The desktop app did not confirm the request. Check T3 Code for its status. No standalone update was attempted.", + }; + } + const reason = safeTransportMessages.has(message) + ? `${message} Check T3 Code and try again.` + : "Could not communicate with T3 Code. Check the desktop app and try again."; + return { state: "error", reason: `${reason} No standalone update was attempted.` }; +} + +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: 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.result; + if (!result.ok) { + 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; + return yield* new DesktopCliUpdateError({ + reason: "This desktop app cannot coordinate t3 update. Update the desktop app first.", + }); +}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 99bf380f1c0d..3041dc4ff030 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, @@ -20,6 +21,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 { @@ -57,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); @@ -243,6 +247,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 +282,95 @@ const versionArgument = Argument.String("version").pipe( Argument.optional, ); +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 CliUpdateError({ 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 + ? yield* encodeUpdateCheck(result) + : `${result.pathway}: ${result.state}. ${result.reason ?? ""}`, + ); + } + if (flags.check) { + const result: T3UpdateCheck = { + 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 ? yield* encodeUpdateCheck(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..e8919d185574 100644 --- a/apps/server/src/cliCompileCache.test.ts +++ b/apps/server/src/cliCompileCache.test.ts @@ -61,6 +61,36 @@ 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 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 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/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, })); 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 new file mode 100644 index 000000000000..a1f97075cf46 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.browser.test.tsx @@ -0,0 +1,225 @@ +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"; +import "../../index.css"; +import { DesktopUpdateCoordinator } from "./DesktopUpdateCoordinator"; +import { ToastProvider } from "../ui/toast"; + +const mocks = vi.hoisted(() => ({ + run: vi.fn(async () => undefined), + hasUpdates: true, + checkProviders: vi.fn(async () => false), +})); +vi.mock("../ProviderUpdatesAction", () => ({ + useCheckProviderUpdates: () => mocks.checkProviders, +})); +vi.mock("../sidebar/useUpdateEverything", () => ({ + useUpdateEverything: () => ({ + hasUpdates: mocks.hasUpdates, + version: "0.1.8", + providerCount: 0, + rows: [ + { environmentId: null, label: "This app", app: "this-app", skipped: null, providers: [] }, + ], + restarts: [], + run: mocks.run, + }), +})); + +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, +}; + +function setup() { + mocks.run.mockClear(); + mocks.hasUpdates = true; + 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 () => 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(); + + 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({ + 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); + 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()); +}); + +it("replaces a disconnected confirmation and ignores its late preparation", async () => { + setup(); + 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"); +}); + +it("explains an expired confirmation without starting an update", async () => { + setup(); + 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 new file mode 100644 index 000000000000..29ad01584366 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopUpdateCoordinator.tsx @@ -0,0 +1,183 @@ +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"; +import { + getDesktopUpdateDownloadedVersion, + resolveDesktopUpdateButtonAction, +} 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 latestRequestId = useRef(null); + 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 checkProviderUpdates = useCheckProviderUpdates(); + const [checkingProviders, setCheckingProviders] = useState(false); + + const receive = useEffectEvent(async (incoming: DesktopUpdateCommandRequest) => { + if (!command) return; + const bridge = window.desktopBridge!; + // 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); + const providerCheck = checkProviderUpdates({ quiet: true }).finally(() => + setCheckingProviders(false), + ); + let current = await bridge.getUpdateState(); + if (latestRequestId.current !== incoming.requestId) return; + if ( + current.status === "idle" || + current.status === "up-to-date" || + current.status === "error" + ) { + current = (await bridge.checkForUpdate()).state; + } + if (latestRequestId.current !== incoming.requestId) return; + 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; + } + if (!current.enabled || resolveDesktopUpdateButtonAction(current) === "none") { + 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); + }); + // 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 () => { + latestRequestId.current = null; + unsubscribe(); + void command.setReady(false, rendererId); + }; + }, [command]); + + 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", + })) + ) { + 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 + .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({ + type: "error", + title: "Could not update T3 Code", + description: error instanceof Error ? error.message : "The coordinated update failed.", + }), + ); + } + }; + if (!request) return null; + return ( + { + if (!open) void cancel(); + }} + version={everything.version} + providerCount={everything.providerCount} + rows={everything.rows} + restarts={everything.restarts} + // Offers found by the refresh reach the rows through the debounced provider-status push. + checking={checkingProviders || (action === "none" && !everything.hasUpdates)} + 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..a14a0ab0202a --- /dev/null +++ b/apps/web/src/components/desktop/desktopUpdateRequest.ts @@ -0,0 +1,27 @@ +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); + 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({ + 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 a37bf1581990..952e427f4762 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"; @@ -37,7 +38,6 @@ import { canCheckForUpdate, getDesktopUpdateButtonTooltip, getDesktopUpdateDisabledReason, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; @@ -110,7 +110,6 @@ import { StorageCleanupSections } from "./StorageSettings"; import { UpdateTrackSwitchDialog } from "./UpdateTrackSwitchDialog"; import { environmentIdsKey } from "~/state/environmentAtomList"; import { environmentSessionStatesAtom } from "~/state/session"; -import { useCheckProviderUpdates } from "../ProviderUpdatesAction"; import { canUpdateServer } from "../ServerUpdateAction"; import { useUpdateEverythingRun } from "../sidebar/updateEverythingRun"; import { awaitsTrackSwitch, channelSwitchLabel } from "../sidebar/updateEverything.logic"; @@ -163,7 +162,6 @@ function AboutVersionSection() { ); // A second run would replace the first run's progress and local download. const updateRun = useUpdateEverythingRun(); - const checkProviderUpdates = useCheckProviderUpdates(); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; @@ -195,91 +193,10 @@ function AboutVersionSection() { ).length; const handleButtonClick = async () => { - const bridge = window.desktopBridge; - if (!bridge) return; - - 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 (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)); - return; - } - - if (typeof bridge.checkForUpdate !== "function") return; - void checkProviderUpdates(); - 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"; @@ -293,8 +210,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/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index f5db42c452c1..0fd9874e764b 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -96,56 +96,74 @@ 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 }), + const switchTrack = 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."); - } + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + fail(error instanceof Error ? error.message : "An unexpected error occurred."); + } + }; + await switchTrack().finally(() => 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 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 3c18b2c7a927..d86ef1f195af 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, @@ -43,11 +42,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< @@ -285,6 +280,12 @@ function SidebarUpdateControl() { const bridge = window.desktopBridge; if (isInteractionDisabled) return; + if (isElectron) { + setIsActionPending(true); + await requestCoordinatedDesktopUpdate(); + setIsActionPending(false); + return; + } if (runsEverything) { setIsConfirmingEverything(true); // Machines that still cache an older latest version add their rows while the dialog is open. @@ -292,42 +293,9 @@ function SidebarUpdateControl() { void checkProviderUpdates({ quiet: true }).finally(() => setProviderCheck(null)); 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); @@ -439,18 +407,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/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 58328dbc1eee..527acc4793b7 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -316,6 +316,35 @@ describe("collectRestartingMachines", () => { }); describe("runUpdateEverything", () => { + it("waits for remote work to settle before reporting a rejected local download", async () => { + 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, + updateProviders: () => remote, + updateServers: async () => "handed-off", + }).then( + () => undefined, + (error: unknown) => { + finished = true; + return error; + }, + ); + rejectLocal?.(localFailure); + await local.catch(() => undefined).then(() => undefined); + expect(finished).toBe(false); + releaseRemote?.(); + 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 c4a3f15b3d12..ce510a89e3f9 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 { @@ -135,6 +136,7 @@ function RootRouteView() { return ( + {isElectron ? : null} diff --git a/docs/user/updating.md b/docs/user/updating.md index 7e62c7bf580f..3900eecbdddc 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -36,6 +36,30 @@ 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. +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 + +The `t3` command installed by the desktop app uses the same coordinated updater as the sidebar and +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, open or update it and try again. + +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. + ## Switch between Stable and Nightly In the desktop app, change **Settings → About → Update track**. T3 Code shows each diff --git a/packages/contracts/src/desktopAppActivation.ts b/packages/contracts/src/desktopAppActivation.ts index 020188cf43eb..d9776786be0c 100644 --- a/packages/contracts/src/desktopAppActivation.ts +++ b/packages/contracts/src/desktopAppActivation.ts @@ -51,3 +51,46 @@ export const DesktopAppActivationResponse = Schema.Union([ DesktopAppActivationFailure, ]); export type DesktopAppActivationResponse = typeof DesktopAppActivationResponse.Type; + +export const T3UpdateCheck = Schema.Struct({ + schema: Schema.Literal("t3.update-check.v1"), + coordinated: Schema.Boolean, + owner: Schema.Literals(["desktop", "standalone"]), + pathway: Schema.Literals(["desktop-coordinated", "standalone"]), + currentVersion: TrimmedNonEmptyString, + targetVersion: Schema.NullOr(TrimmedNonEmptyString), + state: Schema.Literals(["ready", "up-to-date", "unavailable", "busy", "blocked", "error"]), + reason: Schema.optional(TrimmedNonEmptyString), +}); +export type T3UpdateCheck = typeof T3UpdateCheck.Type; + +export const DesktopUpdateCommandRequest = Schema.Struct({ + version: Schema.Literal(1), + requestId: TrimmedNonEmptyString, + type: Schema.Literals(["update", "update-check"]), +}); +export type DesktopUpdateCommandRequest = typeof DesktopUpdateCommandRequest.Type; + +export const DesktopUpdateCommandResponse = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + requestId: TrimmedNonEmptyString, + ok: Schema.Literal(true), + check: T3UpdateCheck, + }), + Schema.Struct({ + version: Schema.Literal(1), + requestId: TrimmedNonEmptyString, + ok: Schema.Literal(true), + status: Schema.Literals(["started", "cancelled", "up-to-date"]), + }), + DesktopAppActivationFailure, +]); +export type DesktopUpdateCommandResponse = typeof DesktopUpdateCommandResponse.Type; + +export const DesktopAppControlRequest = Schema.Union([ + DesktopAppActivationRequest, + DesktopUpdateCommandRequest, +]); +export type DesktopAppControlRequest = typeof DesktopAppControlRequest.Type; +export type DesktopAppControlResponse = DesktopAppActivationResponse | DesktopUpdateCommandResponse; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 29be3d6d0219..137c989ad663 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -18,6 +18,8 @@ import { KeybindingShortcut, type PreviewForwardedShortcut } from "./keybindings import type { DesktopAppActivationRequest, + DesktopUpdateCommandRequest, + DesktopUpdateCommandResponse, DesktopAppActivationResponse, } from "./desktopAppActivation.ts"; @@ -1146,6 +1148,14 @@ export interface DesktopBridge { uninstall: () => Promise; addToPath: () => 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;