Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/app/DesktopState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ export class DesktopState extends Context.Service<
{
readonly backendReady: Ref.Ref<boolean>;
readonly quitting: Ref.Ref<boolean>;
// Set by DesktopUpdates.configure, which runs before the first backend start.
readonly canInstallUpdates: Ref.Ref<boolean>;
readonly primaryBackendStartup: SubscriptionRef.SubscriptionRef<PrimaryBackendStartup>;
}
>()("@t3tools/desktop/app/DesktopState") {}

const make = Effect.all({
backendReady: Ref.make(false),
quitting: Ref.make(false),
canInstallUpdates: Ref.make(false),
primaryBackendStartup: SubscriptionRef.make<PrimaryBackendStartup>({ _tag: "Starting" }),
});

Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";
Expand All @@ -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"),
Expand Down Expand Up @@ -107,6 +112,7 @@ const withHarness = <A, E, R>(
E,
| R
| DesktopEnvironment.DesktopEnvironment
| DesktopState.DesktopState
| FileSystem.FileSystem
| DesktopBackendConfiguration.DesktopBackendConfiguration
>,
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";
Expand Down Expand Up @@ -486,6 +488,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
readonly resourceMonitorPath: Option.Option<string>;
readonly cliPath: Option.Option<string>;
readonly shellEnvironmentPrepared: boolean;
readonly desktopAppUpdate: boolean;
},
): Effect.fn.Return<
DesktopBackendManager.DesktopBackendStartConfig,
Expand All @@ -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, {
Expand Down Expand Up @@ -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<string>());
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
);

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -516,6 +517,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct
...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }),
desktopTelemetryFd,
desktopTelemetryControlFd,
...(desktopAppUpdate === undefined ? {} : { desktopAppUpdate }),
desktopBrowserFd,
desktopBrowserControlFd,
resourceMonitorPath,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,61 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerEnvironmen
}
}),
);
it.effect.each([
{ desktopAppUpdate: false, advertised: false },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking P3 coverage suggestion: add desktopAppUpdate: undefined with desktopTelemetryControlFd present. The bootstrap field is optional, and this strict-true check intentionally fails closed for omitted/older payloads; the current false/true table does not explicitly lock that compatibility boundary. The implementation appears correct.

{ 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: "" },
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/desktopBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading