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
20 changes: 20 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopTray from "./DesktopTray.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
Expand Down Expand Up @@ -223,6 +224,7 @@ const startup = Effect.gen(function* () {
const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
const electronApp = yield* ElectronApp.ElectronApp;
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const tray = yield* DesktopTray.DesktopTray;
const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
const clerk = yield* DesktopClerk.DesktopClerk;
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
Expand Down Expand Up @@ -277,6 +279,24 @@ const startup = Effect.gen(function* () {
Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
);
yield* logStartupInfo("app ready");
// Register system tray after app is ready — Tray requires a ready app on
// Windows. The tray background service is Windows-only: macOS/Linux keep
// their existing lifecycle (no hidden-icons tray, quit on last window).
// Best-effort: tray failures must not take down startup. ForkScoped ties the
// tray's scope to the app's scopedProgram lifetime (until DesktopShutdown),
// so the icon stays alive; do not wrap with Effect.scoped which would close
// the scope right after register and destroy the tray.
if (environment.platform === "win32") {
yield* tray.register.pipe(
Effect.catch((error) =>
logStartupInfo("tray registration failed; continuing without tray", {
error: error.message,
}),
),
Effect.forkScoped,
Effect.asVoid,
);
}
if (environment.platform === "linux") {
const selectedBackend = yield* safeStorage.selectedStorageBackend;
yield* logStartupInfo("safe storage ready", {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const makeAssetsLayer = (png: Option.Option<string>) =>
icns: Option.none(),
png,
}),
resolveTrayIconPath: () => Effect.succeed(Option.none()),
resolveResourcePath: () => Effect.succeed(Option.none()),
} satisfies DesktopAssets.DesktopAssets["Service"]);

Expand Down
63 changes: 57 additions & 6 deletions apps/desktop/src/app/DesktopAssets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ describe("DesktopAssets", () => {
),
);
const fileSystemLayer = FileSystem.layerNoop({
exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")),
exists: (path) =>
Effect.succeed(String(path).replaceAll("\\", "/").includes("/assets/dev/")),
});
const assets = yield* DesktopAssets.DesktopAssets.pipe(
Effect.provide(
Expand All @@ -56,12 +57,59 @@ describe("DesktopAssets", () => {

const icons = yield* assets.iconPaths;

assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/);
assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/);
assert.match(
Option.getOrThrow(icons.ico).replaceAll("\\", "/"),
/assets\/dev\/blueprint-windows\.ico$/,
);
assert.match(
Option.getOrThrow(icons.png).replaceAll("\\", "/"),
/assets\/dev\/blueprint-universal-1024\.png$/,
);
assert.isTrue(Option.isNone(icons.icns));
}),
);

it.effect("prefers the unpackaged nightly tray icon", () =>
Effect.gen(function* () {
const developmentEnvironmentLayer = DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/dist-electron",
homeDirectory: "/Users/alice",
platform: "win32",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: false,
resourcesPath: "/repo/apps/desktop/resources",
runningUnderArm64Translation: false,
}).pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }),
),
),
);
const fileSystemLayer = FileSystem.layerNoop({
exists: (path) =>
Effect.succeed(String(path).replaceAll("\\", "/").includes("/assets/nightly/")),
});
const assets = yield* DesktopAssets.DesktopAssets.pipe(
Effect.provide(
DesktopAssets.layer.pipe(
Layer.provide(Layer.merge(fileSystemLayer, developmentEnvironmentLayer)),
),
),
);

const trayIconPath = yield* assets.resolveTrayIconPath("nightly");

assert.match(
Option.getOrThrow(trayIconPath).replaceAll("\\", "/"),
/assets\/nightly\/nightly-windows\.ico$/,
);
}),
);

it.effect("preserves the failed asset candidate and filesystem cause", () =>
Effect.gen(function* () {
const fileName = "custom.bin";
Expand All @@ -74,7 +122,10 @@ describe("DesktopAssets", () => {
description: "private filesystem diagnostic",
});
const fileSystemLayer = FileSystem.layerNoop({
exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)),
exists: (path) =>
String(path).replaceAll("\\", "/") === candidatePath
? Effect.fail(cause)
: Effect.succeed(false),
});
const assetsLayer = DesktopAssets.layer.pipe(
Layer.provide(Layer.merge(fileSystemLayer, environmentLayer)),
Expand All @@ -85,10 +136,10 @@ describe("DesktopAssets", () => {

assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError);
assert.equal(error.fileName, fileName);
assert.equal(error.candidatePath, candidatePath);
assert.equal(error.candidatePath.replaceAll("\\", "/"), candidatePath);
assert.strictEqual(error.cause, cause);
assert.equal(
error.message,
error.message.replaceAll("\\", "/"),
`Failed to probe desktop asset "${fileName}" at ${candidatePath}.`,
);
assert.notInclude(error.message, "private filesystem diagnostic");
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/app/DesktopAssets.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DesktopUpdateChannel } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -30,6 +31,9 @@ export class DesktopAssets extends Context.Service<
DesktopAssets,
{
readonly iconPaths: Effect.Effect<DesktopIconPaths>;
readonly resolveTrayIconPath: (
updateChannel: DesktopUpdateChannel,
) => Effect.Effect<Option.Option<string>, DesktopAssetProbeError>;
readonly resolveResourcePath: (
fileName: string,
) => Effect.Effect<Option.Option<string>, DesktopAssetProbeError>;
Expand Down Expand Up @@ -90,6 +94,43 @@ function resolveSourceTreeIconPath(
return environment.path.join(environment.rootDir, "assets", brand, fileName);
}

const resolveNightlyTrayIconPath = Effect.fn("desktop.assets.resolveNightlyTrayIconPath")(
function* (
updateChannel: DesktopUpdateChannel,
): Effect.fn.Return<
Option.Option<string>,
DesktopAssetProbeError,
FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
> {
const fileSystem = yield* FileSystem.FileSystem;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
if (environment.isPackaged || updateChannel !== "nightly") {
return Option.none<string>();
}

for (const fileName of ["nightly-windows.ico", "nightly-universal-1024.png"] as const) {
const candidatePath = environment.path.join(
environment.rootDir,
"assets",
"nightly",
fileName,
);
const exists = yield* fileSystem
.exists(candidatePath)
.pipe(
Effect.mapError(
(cause) => new DesktopAssetProbeError({ fileName, candidatePath, cause }),
),
);
if (exists) {
return Option.some(candidatePath);
}
}

return Option.none<string>();
},
);

const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
ext: keyof DesktopIconPaths,
): Effect.fn.Return<
Expand Down Expand Up @@ -131,6 +172,14 @@ export const make = Effect.gen(function* () {

return DesktopAssets.of({
iconPaths: Effect.succeed(iconPaths),
resolveTrayIconPath: Effect.fn("desktop.assets.resolveTrayIconPath")(function* (updateChannel) {
const nightlyIconPath = yield* resolveNightlyTrayIconPath(updateChannel).pipe(
Effect.provide(context),
);
return Option.isSome(nightlyIconPath)
? nightlyIconPath
: Option.orElse(iconPaths.ico, () => iconPaths.png);
}),
resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")(
function* (fileName) {
return yield* resolveResourcePath(fileName).pipe(Effect.provide(context));
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopTray from "./DesktopTray.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";

function makeElectronAppLayer(
Expand Down Expand Up @@ -76,6 +78,17 @@ function makeElectronWindowLayer(destroyAll: Effect.Effect<void> = Effect.void)
});
}

function makeDesktopTrayLayer(isRegistered = false) {
return Layer.succeed(DesktopTray.DesktopTray, {
register: Effect.void,
isRegistered: Effect.succeed(isRegistered),
updateRunningCount: () => Effect.void,
updateTooltip: () => Effect.void,
setAgentsPaused: () => Effect.void,
rebuildMenu: Effect.void,
});
}

function makeDesktopWindowLayer(
input: {
readonly activate?: Effect.Effect<void>;
Expand Down Expand Up @@ -115,6 +128,8 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(makeDesktopTrayLayer()),
Layer.provideMerge(DesktopAppSettings.layerTest()),
);

return Effect.scoped(
Expand Down Expand Up @@ -144,6 +159,65 @@ describe("DesktopLifecycle", () => {
});
}

for (const testCase of [
{ platform: "win32", closeToTray: true, trayRegistered: true, expectQuit: false },
{ platform: "win32", closeToTray: true, trayRegistered: false, expectQuit: true },
{ platform: "win32", closeToTray: false, trayRegistered: true, expectQuit: true },
{ platform: "linux", closeToTray: true, trayRegistered: false, expectQuit: true },
] satisfies ReadonlyArray<{
platform: NodeJS.Platform;
closeToTray: boolean;
trayRegistered: boolean;
expectQuit: boolean;
}>) {
it.effect(
`window-all-closed ${testCase.expectQuit ? "quits" : "stays alive for the tray"} on ${testCase.platform} with closeToTray=${String(testCase.closeToTray)}`,
() =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
let quitCount = 0;
const quit = Effect.sync(() => {
quitCount += 1;
});
const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: testCase.platform,
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(makeElectronAppLayer(appListeners, quit)),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(makeElectronWindowLayer()),
Layer.provideMerge(makeDesktopWindowLayer()),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(makeDesktopTrayLayer(testCase.trayRegistered)),
Layer.provideMerge(
DesktopAppSettings.layerTest({
...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
closeToTray: testCase.closeToTray,
}),
),
);

yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;

appListeners.get("window-all-closed")?.();
// The handler is a forked promise over synchronous mocks; one
// macrotask boundary flushes it deterministically (a scheduler
// flush, not a timed wait).
yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve)));

assert.equal(quitCount, testCase.expectQuit ? 1 : 0);
}),
).pipe(Effect.provide(layer));
}),
);
}

it.effect("destroys windows before waiting for backend shutdown", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
Expand Down Expand Up @@ -185,6 +259,8 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(environmentLayer),
Layer.provideMerge(desktopShutdownLayer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(makeDesktopTrayLayer()),
Layer.provideMerge(DesktopAppSettings.layerTest()),
);

yield* Effect.scoped(
Expand Down Expand Up @@ -226,6 +302,8 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(makeDesktopTrayLayer()),
Layer.provideMerge(DesktopAppSettings.layerTest()),
);

yield* Effect.scoped(
Expand Down
25 changes: 23 additions & 2 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopTray from "./DesktopTray.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";

export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<DesktopLifecycleRelaunchError>()(
"DesktopLifecycleRelaunchError",
Expand All @@ -34,14 +36,16 @@ export type DesktopLifecycleRuntimeServices =
| DesktopState.DesktopState
| DesktopWindow.DesktopWindow
| ElectronApp.ElectronApp
| ElectronTheme.ElectronTheme;
| ElectronTheme.ElectronTheme
| DesktopAppSettings.DesktopAppSettings;

type DesktopLifecycleRegistrationServices =
| DesktopLifecycleRuntimeServices
| DesktopTray.DesktopTray
| ElectronWindow.ElectronWindow;

/**
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
* @effect-expect-leaking DesktopAppSettings | DesktopEnvironment | DesktopShutdown | DesktopState | DesktopTray | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
*/
export class DesktopLifecycle extends Context.Service<
DesktopLifecycle,
Expand Down Expand Up @@ -236,7 +240,24 @@ export const make = DesktopLifecycle.of({
Effect.gen(function* () {
const app = yield* ElectronApp.ElectronApp;
const state = yield* DesktopState.DesktopState;
const settings = yield* DesktopAppSettings.DesktopAppSettings;
const tray = yield* DesktopTray.DesktopTray;
if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
// When background service (closeToTray) is enabled, keep the app alive
// in the tray even after the last window closes — this is the
// expected Windows hidden-icons behavior. Without it, closing the
// window would kill all running agents. Windows-only: elsewhere the
// window handlers destroy on close (DesktopWindow.shouldHideOnClose
// is win32-gated), so suppressing quit would strand a windowless app.
const currentSettings = yield* settings.get;
if (
environment.platform === "win32" &&
currentSettings.closeToTray &&
(yield* tray.isRegistered)
) {
yield* logLifecycleInfo("window-all-closed suppressed by closeToTray");
return;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
yield* app.quit;
}
}).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
Expand Down
Loading
Loading