diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index 3452fd13c0807..f869fd335ab07 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -17,6 +17,8 @@ export class DesktopState extends Context.Service< { readonly backendReady: Ref.Ref; readonly quitting: Ref.Ref; + // Set by DesktopUpdates.configure, which runs before the first backend start. + readonly canInstallUpdates: Ref.Ref; readonly primaryBackendStartup: SubscriptionRef.SubscriptionRef; } >()("@t3tools/desktop/app/DesktopState") {} @@ -24,6 +26,7 @@ export class DesktopState extends Context.Service< const make = Effect.all({ backendReady: Ref.make(false), quitting: Ref.make(false), + canInstallUpdates: Ref.make(false), primaryBackendStartup: SubscriptionRef.make({ _tag: "Starting" }), }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index aabc374798f24..968ec20ea6da1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -9,6 +9,7 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; @@ -18,6 +19,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; @@ -30,7 +32,10 @@ const layerShellEnvironment = (prepared = true) => }); const layerBackendConfiguration = (prepared = true) => - DesktopBackendConfiguration.layer.pipe(Layer.provide(layerShellEnvironment(prepared))); + DesktopBackendConfiguration.layer.pipe( + Layer.provide(layerShellEnvironment(prepared)), + Layer.provideMerge(DesktopState.layer), + ); const layerServerExposure = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), @@ -107,6 +112,7 @@ const withHarness = ( E, | R | DesktopEnvironment.DesktopEnvironment + | DesktopState.DesktopState | FileSystem.FileSystem | DesktopBackendConfiguration.DesktopBackendConfiguration >, @@ -275,6 +281,21 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect.each([{ canInstallUpdates: false }, { canInstallUpdates: true }])( + "resolvePrimary tells the backend whether the desktop can update (canInstallUpdates: $canInstallUpdates)", + ({ canInstallUpdates }) => + withHarness( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + yield* Ref.set(desktopState.canInstallUpdates, canInstallUpdates); + + const config = yield* configuration.resolvePrimary; + assert.equal(config.bootstrap.desktopAppUpdate, canInstallUpdates); + }), + ), + ); + it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 69a70b68a4b3d..73f405baccfda 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -11,6 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -19,6 +20,7 @@ import serverPackageJson from "../../../server/package.json" with { type: "json" import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopCliShim from "../app/DesktopCliShim.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -486,6 +488,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv readonly resourceMonitorPath: Option.Option; readonly cliPath: Option.Option; readonly shellEnvironmentPrepared: boolean; + readonly desktopAppUpdate: boolean; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -509,6 +512,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv tailscaleServePort: backendExposure.tailscaleServePort, desktopTelemetryFd: 4, desktopTelemetryControlFd: 5, + desktopAppUpdate: input.desktopAppUpdate, desktopBrowserFd: 6, desktopBrowserControlFd: 7, ...Option.match(input.resourceMonitorPath, { @@ -723,6 +727,7 @@ export const make = Effect.gen(function* () { const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; + const desktopState = yield* DesktopState.DesktopState; const random = yield* SecureRandom.SecureRandom; // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); @@ -789,6 +794,7 @@ export const make = Effect.gen(function* () { resourceMonitorPath, cliPath, shellEnvironmentPrepared, + desktopAppUpdate: yield* Ref.get(desktopState.canInstallUpdates), }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..f3beeef91c866 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -29,16 +29,22 @@ describe("DesktopUpdates", () => { Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + const desktopState = yield* DesktopState.DesktopState; yield* updates.configure; - return yield* updates.getState; + return { + state: yield* updates.getState, + canInstallUpdates: yield* Ref.get(desktopState.canInstallUpdates), + }; }), ).pipe(Effect.provide(makeHarness({ platform: "linux", packageType }).layer)); const deb = yield* linuxState("deb\n"); - assert.equal(deb.status, "idle"); + assert.equal(deb.state.status, "idle"); + assert.isTrue(deb.canInstallUpdates); const unmarked = yield* linuxState(undefined); - assert.equal(unmarked.status, "disabled"); + assert.equal(unmarked.state.status, "disabled"); + assert.isFalse(unmarked.canInstallUpdates); }), ); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6279e8b6141a8..cf8429588849b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1144,6 +1144,8 @@ export const make = Effect.gen(function* () { const settings = yield* desktopSettings.get; const disabledReason = yield* resolveDisabledReason; + // A blocked nightly channel still lets the user switch back to stable, so it stays updatable. + yield* Ref.set(desktopState.canInstallUpdates, Option.isNone(disabledReason)); yield* setState( yield* baseUpdateState( settings.updateChannel, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 00aae0f3133b7..a7b9584d1a7e6 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -383,6 +383,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct const shellEnvironmentPrepared = bootstrap?.shellEnvironmentPrepared; const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; + const desktopAppUpdate = bootstrap?.desktopAppUpdate; const desktopBrowserFd = bootstrap?.desktopBrowserFd; const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; const resourceMonitorPath = bootstrap?.resourceMonitorPath; @@ -516,6 +517,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct ...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }), desktopTelemetryFd, desktopTelemetryControlFd, + ...(desktopAppUpdate === undefined ? {} : { desktopAppUpdate }), desktopBrowserFd, desktopBrowserControlFd, resourceMonitorPath, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 468cc89ad01e6..47720df5fd3fb 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -99,6 +99,7 @@ export class ServerConfig extends Context.Service< readonly shellEnvironmentPrepared?: boolean | undefined; readonly desktopTelemetryFd?: number | undefined; readonly desktopTelemetryControlFd?: number | undefined; + readonly desktopAppUpdate?: boolean | undefined; readonly desktopBrowserFd?: number | undefined; readonly desktopBrowserControlFd?: number | undefined; readonly resourceMonitorPath?: string | undefined; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 3960b5b8d1634..e162317da7d56 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -123,6 +123,61 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerEnvironmen } }), ); + it.effect.each([ + { desktopAppUpdate: false, advertised: false }, + { desktopAppUpdate: true, advertised: true }, + ])( + "advertises desktop app updates only when the desktop can update (desktopAppUpdate: $desktopAppUpdate)", + ({ desktopAppUpdate, advertised }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped(); + const config = yield* makeServerConfig(baseDir); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + const descriptor = yield* Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(layerEmptySecretStore), + Layer.provide( + ServerConfig.layer({ + ...config, + mode: "desktop", + desktopTelemetryControlFd: 5, + desktopAppUpdate, + }), + ), + ), + ), + Effect.provideService(HostProcess.Environment, {}), + ); + expect(descriptor.capabilities.serverSelfUpdate).toBe("desktop-managed"); + expect( + { + desktopAppUpdate: descriptor.capabilities.desktopAppUpdate, + desktopAppChannelSwitch: descriptor.capabilities.desktopAppChannelSwitch, + serverSelfUpdateProgress: descriptor.capabilities.serverSelfUpdateProgress, + }, + advertised + ? "desktop can update but desktopAppUpdate is not advertised" + : "advertises desktopAppUpdate although the desktop cannot update", + ).toEqual( + advertised + ? { + desktopAppUpdate: true, + desktopAppChannelSwitch: true, + serverSelfUpdateProgress: true, + } + : { + desktopAppUpdate: undefined, + desktopAppChannelSwitch: undefined, + serverSelfUpdateProgress: undefined, + }, + ); + }), + ); it.effect.each([ { name: "missing", content: undefined }, { name: "empty", content: "" }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0787a3702c009..6e5a46b25bc8a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -199,10 +199,11 @@ export const make = Effect.gen(function* () { launcherManaged: launcher.managed, }); const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null; - // Desktop and server ship together; a control fd guarantees requestDesktopUpdate support. - + // The desktop reports whether it can install updates; requests travel over its control fd. const desktopAppUpdate = - serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; + serverSelfUpdate === "desktop-managed" && + serverConfig.desktopAppUpdate === true && + serverConfig.desktopTelemetryControlFd !== undefined; const databaseMigration = databaseMigrationHead(serverConfig.dbPath); const descriptor: ExecutionEnvironmentDescriptor = { diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index 6bec048a56b4e..48233f4d94d52 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -26,6 +26,8 @@ export const DesktopBackendBootstrap = Schema.Struct({ otlpLogsUrl: Schema.optional(Schema.String), desktopTelemetryFd: Schema.optionalKey(PositiveInt), desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), + // Whether the spawning desktop app can install updates when the server requests one. + desktopAppUpdate: Schema.optionalKey(Schema.Boolean), desktopBrowserFd: Schema.optionalKey(PositiveInt), desktopBrowserControlFd: Schema.optionalKey(PositiveInt), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),