From ed597dfdd6bc64c535fa335e0a3ce8004624507a Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sun, 6 Sep 2026 00:43:29 +0800 Subject: [PATCH 1/7] feat(desktop): add ElectronTray service --- .../desktop/src/electron/ElectronTray.test.ts | 146 ++++++++++++++++++ apps/desktop/src/electron/ElectronTray.ts | 134 ++++++++++++++++ apps/desktop/src/main.ts | 2 + 3 files changed, 282 insertions(+) create mode 100644 apps/desktop/src/electron/ElectronTray.test.ts create mode 100644 apps/desktop/src/electron/ElectronTray.ts diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts new file mode 100644 index 000000000000..f5eb73f4d2bb --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -0,0 +1,146 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vite-plus/test"; + +interface MockTrayInstance { + icon: unknown; + toolTip?: string; + contextMenu?: unknown; + listeners: Map void>; + destroyed: boolean; + destroy: () => void; + isDestroyed: () => boolean; + setToolTip: (tip: string) => void; + setContextMenu: (menu: unknown) => void; + on: (event: string, listener: (...args: unknown[]) => void) => void; +} + +const { buildFromTemplateMock, createFromPathMock, trayInstances } = vi.hoisted(() => { + const instances: MockTrayInstance[] = []; + + return { + buildFromTemplateMock: vi.fn((template) => ({ template })), + createFromPathMock: vi.fn((path: string) => ({ + isEmpty: () => false, + path, + })), + trayInstances: instances, + }; +}); + +vi.mock("electron", () => { + class MockTray { + toolTip?: string; + contextMenu?: unknown; + listeners = new Map void>(); + destroyed = false; + icon: unknown; + + constructor(icon: unknown) { + this.icon = icon; + trayInstances.push(this); + } + + setToolTip(tip: string) { + this.toolTip = tip; + } + + setContextMenu(menu: unknown) { + this.contextMenu = menu; + } + + on(event: string, listener: (...args: unknown[]) => void) { + this.listeners.set(event, listener); + } + + destroy() { + this.destroyed = true; + } + + isDestroyed() { + return this.destroyed; + } + } + + return { + Menu: { + buildFromTemplate: buildFromTemplateMock, + }, + nativeImage: { + createFromPath: createFromPathMock, + }, + Tray: MockTray, + }; +}); + +import * as ElectronTray from "./ElectronTray.ts"; + +const TestLayer = ElectronTray.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "win32")), +); + +describe("ElectronTray", () => { + beforeEach(() => { + buildFromTemplateMock.mockClear(); + createFromPathMock.mockClear(); + trayInstances.length = 0; + }); + + it.effect("creates tray with tooltip and context menu and click listeners", () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + let clicked = false; + let doubleClicked = false; + + yield* electronTray.create({ + iconPath: "C:/path/to/icon.ico", + tooltip: "T3 Code", + onClick: () => { + clicked = true; + }, + onDoubleClick: () => { + doubleClicked = true; + }, + menuItems: [ + { label: "Open T3 Code", click: () => {} }, + { type: "separator" }, + { label: "Quit", click: () => {} }, + ], + }); + + assert.equal(trayInstances.length, 1); + const instance = trayInstances[0]; + assert.isDefined(instance); + assert.equal(instance?.toolTip, "T3 Code"); + assert.isTrue(instance?.listeners.has("click")); + assert.isTrue(instance?.listeners.has("double-click")); + assert.equal(buildFromTemplateMock.mock.calls.length, 1); + + instance?.listeners.get("click")?.(); + assert.isTrue(clicked); + instance?.listeners.get("double-click")?.(); + assert.isTrue(doubleClicked); + + yield* electronTray.destroy; + assert.isTrue(instance?.destroyed); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("destroys old tray before creating a new one", () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + + yield* electronTray.create({ iconPath: "icon1.ico" }); + assert.equal(trayInstances.length, 1); + assert.isFalse(trayInstances[0]?.destroyed); + + yield* electronTray.create({ iconPath: "icon2.ico" }); + assert.equal(trayInstances.length, 2); + assert.isTrue(trayInstances[0]?.destroyed); + assert.isFalse(trayInstances[1]?.destroyed); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts new file mode 100644 index 000000000000..f390c30c5a3f --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -0,0 +1,134 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as Electron from "electron"; + +export class ElectronTrayCreateError extends Schema.TaggedErrorClass()( + "ElectronTrayCreateError", + { + iconPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create Electron Tray with icon "${this.iconPath}".`; + } +} + +export class ElectronTrayOperationError extends Schema.TaggedErrorClass()( + "ElectronTrayOperationError", + { + operation: Schema.String, + platform: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron tray operation "${this.operation}" failed on ${this.platform}.`; + } +} + +export interface ElectronTrayMenuItem { + readonly label?: string; + readonly type?: "normal" | "separator" | "submenu" | "checkbox" | "radio"; + readonly click?: () => void; + readonly enabled?: boolean; +} + +export interface ElectronTrayCreateOptions { + readonly iconPath: string; + readonly tooltip?: string; + readonly menuItems?: readonly ElectronTrayMenuItem[]; + readonly onClick?: () => void; + readonly onDoubleClick?: () => void; +} + +export class ElectronTray extends Context.Service< + ElectronTray, + { + readonly create: ( + options: ElectronTrayCreateOptions, + ) => Effect.Effect; + readonly destroy: Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTray") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const currentTrayRef = yield* Ref.make>(Option.none()); + + const destroy = Effect.gen(function* () { + const currentTray = yield* Ref.getAndSet(currentTrayRef, Option.none()); + if (Option.isSome(currentTray)) { + yield* Effect.try({ + try: () => { + if (!currentTray.value.isDestroyed()) { + currentTray.value.destroy(); + } + }, + catch: (cause) => + new ElectronTrayOperationError({ + operation: "destroy-tray", + platform, + cause, + }), + }).pipe(Effect.orDie); + } + }); + + const create = (options: ElectronTrayCreateOptions) => + Effect.gen(function* () { + yield* destroy; + + const tray = yield* Effect.try({ + try: () => { + const icon = Electron.nativeImage.createFromPath(options.iconPath); + const newTray = new Electron.Tray(icon.isEmpty() ? options.iconPath : icon); + if (options.tooltip) { + newTray.setToolTip(options.tooltip); + } + if (options.menuItems && options.menuItems.length > 0) { + const template: Electron.MenuItemConstructorOptions[] = options.menuItems.map( + (item) => { + const menuItem: Electron.MenuItemConstructorOptions = {}; + if (item.label !== undefined) menuItem.label = item.label; + if (item.type !== undefined) menuItem.type = item.type; + if (item.click !== undefined) menuItem.click = item.click; + if (item.enabled !== undefined) menuItem.enabled = item.enabled; + return menuItem; + }, + ); + const contextMenu = Electron.Menu.buildFromTemplate(template); + newTray.setContextMenu(contextMenu); + } + if (options.onClick) { + newTray.on("click", options.onClick); + } + if (options.onDoubleClick) { + newTray.on("double-click", options.onDoubleClick); + } + return newTray; + }, + catch: (cause) => + new ElectronTrayCreateError({ + iconPath: options.iconPath, + cause, + }), + }); + + yield* Ref.set(currentTrayRef, Option.some(tray)); + return tray; + }); + + return ElectronTray.of({ + create, + destroy, + }); +}); + +export const layer = Layer.effect(ElectronTray, make); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa962..7d6e189ba7ac 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,6 +29,7 @@ import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; +import * as ElectronTray from "./electron/ElectronTray.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; @@ -126,6 +127,7 @@ const electronLayer = Layer.mergeAll( ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, + ElectronTray.layer, ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), From f9740a8654cb2a36a2ed3e99cd78af97b8d5556b Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sun, 6 Sep 2026 00:44:09 +0800 Subject: [PATCH 2/7] feat(desktop): support system tray and persist on close on Windows --- apps/desktop/src/app/DesktopApp.ts | 3 + apps/desktop/src/app/DesktopLifecycle.test.ts | 128 ++++++++++++++++ apps/desktop/src/app/DesktopLifecycle.ts | 22 ++- apps/desktop/src/app/DesktopTray.test.ts | 142 ++++++++++++++++++ apps/desktop/src/app/DesktopTray.ts | 125 +++++++++++++++ .../desktop/src/electron/ElectronTray.test.ts | 13 +- apps/desktop/src/electron/ElectronTray.ts | 25 +-- apps/desktop/src/main.ts | 2 + 8 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/app/DesktopTray.test.ts create mode 100644 apps/desktop/src/app/DesktopTray.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d5a8ac3b7836..50787833a143 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -28,6 +28,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; @@ -236,6 +237,7 @@ const startup = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const tray = yield* DesktopTray.DesktopTray; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -292,6 +294,7 @@ const startup = Effect.gen(function* () { } yield* appIdentity.configure; yield* applicationMenu.configure; + yield* tray.configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* linuxUrlHandler.register; diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 0086edf20bf9..d30a8b94ee39 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -13,8 +13,17 @@ 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 DesktopWindow from "../window/DesktopWindow.ts"; +function makeDesktopTrayLayer(isAvailable = true) { + return Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + isAvailable: Effect.succeed(isAvailable), + destroy: Effect.void, + } satisfies DesktopTray.DesktopTray["Service"]); +} + function makeElectronAppLayer( appListeners: Map void>, quit: Effect.Effect = Effect.void, @@ -119,6 +128,7 @@ describe("DesktopLifecycle", () => { ), ), Layer.provideMerge(makeDesktopWindowLayer()), + Layer.provideMerge(makeDesktopTrayLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), @@ -153,6 +163,122 @@ describe("DesktopLifecycle", () => { }); } + it.effect("does not quit on window-all-closed on win32 (with tray) or darwin", () => + Effect.gen(function* () { + for (const platform of ["win32", "darwin"] as const) { + const appListeners = new Map void>(); + let quitCalled = false; + const quit = Effect.sync(() => { + quitCalled = true; + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + 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(makeDesktopTrayLayer(true)), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + appListeners.get("window-all-closed")?.(); + yield* Effect.yieldNow; + + assert.isFalse(quitCalled, `expected quit not to be called on ${platform}`); + }), + ).pipe(Effect.provide(layer)); + } + }), + ); + + it.effect("quits on window-all-closed on win32 when tray is unavailable", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + let quitCalled = false; + const quit = Effect.sync(() => { + quitCalled = true; + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "win32", + 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(makeDesktopTrayLayer(false)), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + appListeners.get("window-all-closed")?.(); + yield* Effect.yieldNow; + + assert.isTrue(quitCalled, "expected quit to be called on win32 when tray unavailable"); + }), + ).pipe(Effect.provide(layer)); + }), + ); + + it.effect("quits on window-all-closed on linux", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + let quitCalled = false; + const quit = Effect.sync(() => { + quitCalled = true; + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "linux", + 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(makeDesktopTrayLayer()), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + appListeners.get("window-all-closed")?.(); + yield* Effect.yieldNow; + + assert.isTrue(quitCalled, "expected quit to be called on linux"); + }), + ).pipe(Effect.provide(layer)); + }), + ); + it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { const appListeners = new Map void>(); @@ -191,6 +317,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(electronThemeLayer), Layer.provideMerge(makeElectronWindowLayer(destroyAll)), Layer.provideMerge(makeDesktopWindowLayer({ flushMainWindowBounds })), + Layer.provideMerge(makeDesktopTrayLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(desktopShutdownLayer), Layer.provideMerge(DesktopState.layer), @@ -232,6 +359,7 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(electronThemeLayer), Layer.provideMerge(makeElectronWindowLayer()), Layer.provideMerge(makeDesktopWindowLayer({ activate })), + Layer.provideMerge(makeDesktopTrayLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 0a0cc6dca933..6ed8bc86856d 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -10,6 +10,7 @@ import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -32,6 +33,7 @@ export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopShutdown.DesktopShutdown | DesktopState.DesktopState + | DesktopTray.DesktopTray | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp | ElectronTheme.ElectronTheme; @@ -41,7 +43,7 @@ type DesktopLifecycleRegistrationServices = | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow + * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopTray | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, @@ -189,6 +191,7 @@ export const make = DesktopLifecycle.of({ }), register: Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; + const desktopTray = yield* DesktopTray.DesktopTray; const electronWindow = yield* ElectronWindow.ElectronWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; @@ -245,9 +248,22 @@ export const make = DesktopLifecycle.of({ Effect.gen(function* () { const app = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; - if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { - yield* app.quit; + if (yield* Ref.get(state.quitting)) { + return; } + + if (environment.platform === "darwin") { + return; + } + + if (environment.platform === "win32") { + const hasTray = yield* desktopTray.isAvailable; + if (hasTray) { + return; + } + } + + yield* app.quit; }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), ); }); diff --git a/apps/desktop/src/app/DesktopTray.test.ts b/apps/desktop/src/app/DesktopTray.test.ts new file mode 100644 index 000000000000..3a05398d955d --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -0,0 +1,142 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopTray from "./DesktopTray.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +const makeEnvironmentLayer = (platform: "win32" | "darwin" | "linux") => + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform, + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, + displayName: "T3 Code", + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.some("C:/path/to/icon.ico"), + icns: Option.none(), + png: Option.some("C:/path/to/icon.png"), + }), + resolveResourcePath: () => Effect.succeed(Option.none()), +} satisfies DesktopAssets.DesktopAssets["Service"]); + +const makeElectronTrayLayer = ( + createdOptions: Deferred.Deferred, +) => + Layer.succeed(ElectronTray.ElectronTray, { + create: (options) => + Deferred.succeed(createdOptions, options).pipe(Effect.andThen(Effect.succeed({} as any))), + destroy: Effect.void, + } satisfies ElectronTray.ElectronTray["Service"]); + +const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + getAppMetrics: Effect.succeed([]), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, + on: () => Effect.void, +} satisfies ElectronApp.ElectronApp["Service"]); + +const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, + syncAppearance: Effect.void, +} satisfies DesktopWindow.DesktopWindow["Service"]); + +describe("DesktopTray", () => { + it.effect("configures tray icon and menu on win32", () => + Effect.gen(function* () { + const createdOptions = yield* Deferred.make(); + const testLayer = DesktopTray.layer.pipe( + Layer.provideMerge(makeElectronTrayLayer(createdOptions)), + Layer.provideMerge(desktopAssetsLayer), + Layer.provideMerge(makeEnvironmentLayer("win32")), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(NodeServices.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const tray = yield* DesktopTray.DesktopTray; + assert.isFalse(yield* tray.isAvailable); + + yield* tray.configure; + + const options = yield* Deferred.await(createdOptions); + assert.equal(options.iconPath, "C:/path/to/icon.ico"); + assert.equal(options.tooltip, "T3 Code"); + assert.isDefined(options.menuItems); + const menuItems = options.menuItems ?? []; + assert.equal(menuItems.length, 3); + assert.equal(menuItems[0]?.label, "Open T3 Code"); + assert.equal(menuItems[2]?.label, "Quit T3 Code"); + assert.isTrue(yield* tray.isAvailable); + }), + ).pipe(Effect.provide(testLayer)); + }), + ); + + it.effect("skips configuring tray on darwin", () => + Effect.gen(function* () { + const createdOptions = yield* Deferred.make(); + const testLayer = DesktopTray.layer.pipe( + Layer.provideMerge(makeElectronTrayLayer(createdOptions)), + Layer.provideMerge(desktopAssetsLayer), + Layer.provideMerge(makeEnvironmentLayer("darwin")), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(NodeServices.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const tray = yield* DesktopTray.DesktopTray; + yield* tray.configure; + + assert.isFalse(yield* Deferred.isDone(createdOptions)); + assert.isFalse(yield* tray.isAvailable); + }), + ).pipe(Effect.provide(testLayer)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts new file mode 100644 index 000000000000..b4d992af1f8b --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.ts @@ -0,0 +1,125 @@ +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +export class DesktopTrayConfigureError extends Schema.TaggedErrorClass()( + "DesktopTrayConfigureError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to configure desktop system tray."; + } +} + +export class DesktopTray extends Context.Service< + DesktopTray, + { + readonly configure: Effect.Effect; + readonly isAvailable: Effect.Effect; + readonly destroy: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopTray") {} + +const { + logInfo: logTrayInfo, + logError: logTrayError, + logWarning: logTrayWarning, +} = makeComponentLogger("desktop-tray"); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopAssets = yield* DesktopAssets.DesktopAssets; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronTray = yield* ElectronTray.ElectronTray; + const electronApp = yield* ElectronApp.ElectronApp; + const availableRef = yield* Ref.make(false); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const destroy = Effect.gen(function* () { + yield* electronTray.destroy; + yield* Ref.set(availableRef, false); + }); + + const isAvailable = Ref.get(availableRef); + + const configure: Effect.Effect = Effect.gen(function* () { + // System tray is active on Windows to retain background running when windows are closed + if (environment.platform !== "win32") { + return; + } + + const iconPaths = yield* desktopAssets.iconPaths; + const iconPath = Option.getOrElse( + iconPaths.ico, + () => Option.getOrUndefined(iconPaths.png) ?? "", + ); + + if (!iconPath) { + yield* logTrayWarning("tray icon path not found, skipping tray initialization"); + return; + } + + const createEffect = electronTray.create({ + iconPath, + tooltip: environment.displayName, + onClick: () => { + void runPromise(desktopWindow.activate); + }, + onDoubleClick: () => { + void runPromise(desktopWindow.activate); + }, + menuItems: [ + { + label: `Open ${environment.displayName}`, + click: () => { + void runPromise(desktopWindow.activate); + }, + }, + { + type: "separator", + }, + { + label: `Quit ${environment.displayName}`, + click: () => { + void runPromise(electronApp.quit); + }, + }, + ], + }); + + yield* createEffect.pipe( + Effect.mapError((cause) => new DesktopTrayConfigureError({ cause })), + Effect.tap(() => Ref.set(availableRef, true)), + Effect.tap(() => logTrayInfo("system tray configured successfully")), + Effect.catchIf( + () => true, + (error) => + Effect.gen(function* () { + yield* Ref.set(availableRef, false); + yield* logTrayError("failed to initialize system tray", { error: error.message }); + }), + ), + ); + }).pipe(Effect.withSpan("desktop.tray.configure")); + + return DesktopTray.of({ + configure, + isAvailable, + destroy, + }); +}); + +export const layer = Layer.effect(DesktopTray, make); diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts index f5eb73f4d2bb..6afdaa67eb98 100644 --- a/apps/desktop/src/electron/ElectronTray.test.ts +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -105,7 +105,11 @@ describe("ElectronTray", () => { doubleClicked = true; }, menuItems: [ - { label: "Open T3 Code", click: () => {} }, + { + label: "Open T3 Code", + type: "submenu", + submenu: [{ label: "Nested Action", click: () => {} }], + }, { type: "separator" }, { label: "Quit", click: () => {} }, ], @@ -119,6 +123,13 @@ describe("ElectronTray", () => { assert.isTrue(instance?.listeners.has("double-click")); assert.equal(buildFromTemplateMock.mock.calls.length, 1); + const template = ( + buildFromTemplateMock.mock.calls[0] as unknown as [Electron.MenuItemConstructorOptions[]] + )[0]; + assert.equal(template[0]?.label, "Open T3 Code"); + assert.equal(template[0]?.type, "submenu"); + assert.isDefined(template[0]?.submenu); + instance?.listeners.get("click")?.(); assert.isTrue(clicked); instance?.listeners.get("double-click")?.(); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts index f390c30c5a3f..c84420ff9d54 100644 --- a/apps/desktop/src/electron/ElectronTray.ts +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -38,6 +38,7 @@ export interface ElectronTrayMenuItem { readonly type?: "normal" | "separator" | "submenu" | "checkbox" | "radio"; readonly click?: () => void; readonly enabled?: boolean; + readonly submenu?: readonly ElectronTrayMenuItem[]; } export interface ElectronTrayCreateOptions { @@ -58,6 +59,19 @@ export class ElectronTray extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTray") {} +const mapMenuItems = ( + items: readonly ElectronTrayMenuItem[], +): Electron.MenuItemConstructorOptions[] => + items.map((item) => { + const menuItem: Electron.MenuItemConstructorOptions = {}; + if (item.label !== undefined) menuItem.label = item.label; + if (item.type !== undefined) menuItem.type = item.type; + if (item.click !== undefined) menuItem.click = item.click; + if (item.enabled !== undefined) menuItem.enabled = item.enabled; + if (item.submenu !== undefined) menuItem.submenu = mapMenuItems(item.submenu); + return menuItem; + }); + export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const currentTrayRef = yield* Ref.make>(Option.none()); @@ -93,16 +107,7 @@ export const make = Effect.gen(function* () { newTray.setToolTip(options.tooltip); } if (options.menuItems && options.menuItems.length > 0) { - const template: Electron.MenuItemConstructorOptions[] = options.menuItems.map( - (item) => { - const menuItem: Electron.MenuItemConstructorOptions = {}; - if (item.label !== undefined) menuItem.label = item.label; - if (item.type !== undefined) menuItem.type = item.type; - if (item.click !== undefined) menuItem.click = item.click; - if (item.enabled !== undefined) menuItem.enabled = item.enabled; - return menuItem; - }, - ); + const template = mapMenuItems(options.menuItems); const contextMenu = Electron.Menu.buildFromTemplate(template); newTray.setContextMenu(contextMenu); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7d6e189ba7ac..dbf95f88e9c1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -58,6 +58,7 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopTray from "./app/DesktopTray.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; @@ -198,6 +199,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, desktopAppActivationLayer, DesktopApplicationMenu.layer, + DesktopTray.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, From 83929392361ee01e054565cb5349a8d305fcc09c Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Fri, 11 Sep 2026 23:39:50 +0800 Subject: [PATCH 3/7] fix(desktop): clean up partial tray instance on creation failure Keep a reference to the newly created Electron Tray during initialization and ensure it is destroyed if subsequent menu building, tooltip, or event listener registration throws. This prevents leaking orphaned tray icons and ensures fallback exit behavior remains active on Windows. --- .../desktop/src/electron/ElectronTray.test.ts | 20 +++++++++ apps/desktop/src/electron/ElectronTray.ts | 45 ++++++++++++------- apps/desktop/src/ipc/methods/wsl.test.ts | 5 +++ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts index 6afdaa67eb98..2df23c310d18 100644 --- a/apps/desktop/src/electron/ElectronTray.test.ts +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -154,4 +154,24 @@ describe("ElectronTray", () => { assert.isFalse(trayInstances[1]?.destroyed); }).pipe(Effect.provide(TestLayer)), ); + + it.effect("cleans up partial tray if menu building fails during creation", () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + buildFromTemplateMock.mockImplementationOnce(() => { + throw new Error("Menu template build failure"); + }); + + const result = yield* electronTray + .create({ + iconPath: "C:/path/to/icon.ico", + menuItems: [{ label: "Invalid Item" }], + }) + .pipe(Effect.flip); + + assert.instanceOf(result, ElectronTray.ElectronTrayCreateError); + assert.equal(trayInstances.length, 1); + assert.isTrue(trayInstances[0]?.destroyed); + }).pipe(Effect.provide(TestLayer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts index c84420ff9d54..324caff2b28c 100644 --- a/apps/desktop/src/electron/ElectronTray.ts +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -101,23 +101,36 @@ export const make = Effect.gen(function* () { const tray = yield* Effect.try({ try: () => { - const icon = Electron.nativeImage.createFromPath(options.iconPath); - const newTray = new Electron.Tray(icon.isEmpty() ? options.iconPath : icon); - if (options.tooltip) { - newTray.setToolTip(options.tooltip); + let partialTray: Electron.Tray | undefined; + try { + const icon = Electron.nativeImage.createFromPath(options.iconPath); + const newTray = new Electron.Tray(icon.isEmpty() ? options.iconPath : icon); + partialTray = newTray; + if (options.tooltip) { + newTray.setToolTip(options.tooltip); + } + if (options.menuItems && options.menuItems.length > 0) { + const template = mapMenuItems(options.menuItems); + const contextMenu = Electron.Menu.buildFromTemplate(template); + newTray.setContextMenu(contextMenu); + } + if (options.onClick) { + newTray.on("click", options.onClick); + } + if (options.onDoubleClick) { + newTray.on("double-click", options.onDoubleClick); + } + return newTray; + } catch (cause) { + if (partialTray && !partialTray.isDestroyed()) { + try { + partialTray.destroy(); + } catch { + // ignore + } + } + throw cause; } - if (options.menuItems && options.menuItems.length > 0) { - const template = mapMenuItems(options.menuItems); - const contextMenu = Electron.Menu.buildFromTemplate(template); - newTray.setContextMenu(contextMenu); - } - if (options.onClick) { - newTray.on("click", options.onClick); - } - if (options.onDoubleClick) { - newTray.on("double-click", options.onDoubleClick); - } - return newTray; }, catch: (cause) => new ElectronTrayCreateError({ diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 38435e286fa7..05ef59185503 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -9,6 +9,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; +import * as DesktopTray from "../../app/DesktopTray.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; @@ -61,6 +62,10 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( {} as DesktopEnvironment.DesktopEnvironment["Service"], ), ), + Layer.succeed( + DesktopTray.DesktopTray, + DesktopTray.DesktopTray.of({} as DesktopTray.DesktopTray["Service"]), + ), Layer.succeed( DesktopWindow.DesktopWindow, DesktopWindow.DesktopWindow.of({} as DesktopWindow.DesktopWindow["Service"]), From 2b0df2cb0070c3723a394fea3ca3cf9e26278e22 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sat, 12 Sep 2026 00:23:27 +0800 Subject: [PATCH 4/7] docs(desktop): document DesktopTray and ElectronTray services - Add JSDoc comments to exported classes, interfaces, and helper functions in DesktopTray and ElectronTray --- apps/desktop/src/app/DesktopTray.ts | 12 ++++++++++++ apps/desktop/src/electron/ElectronTray.ts | 24 +++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts index b4d992af1f8b..fc6188cfbae0 100644 --- a/apps/desktop/src/app/DesktopTray.ts +++ b/apps/desktop/src/app/DesktopTray.ts @@ -12,6 +12,9 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +/** + * Error raised when configuring the high-level desktop system tray fails. + */ export class DesktopTrayConfigureError extends Schema.TaggedErrorClass()( "DesktopTrayConfigureError", { @@ -23,6 +26,9 @@ export class DesktopTrayConfigureError extends Schema.TaggedErrorClass()( "ElectronTrayCreateError", { @@ -20,6 +23,9 @@ export class ElectronTrayCreateError extends Schema.TaggedErrorClass()( "ElectronTrayOperationError", { @@ -33,6 +39,9 @@ export class ElectronTrayOperationError extends Schema.TaggedErrorClass void; } +/** + * Low-level service managing Electron's native system tray instance. + */ export class ElectronTray extends Context.Service< ElectronTray, { @@ -59,6 +74,9 @@ export class ElectronTray extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTray") {} +/** + * Recursively maps typed menu item descriptors to Electron MenuItemConstructorOptions. + */ const mapMenuItems = ( items: readonly ElectronTrayMenuItem[], ): Electron.MenuItemConstructorOptions[] => @@ -72,6 +90,9 @@ const mapMenuItems = ( return menuItem; }); +/** + * Effect constructor creating the ElectronTray service. + */ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const currentTrayRef = yield* Ref.make>(Option.none()); @@ -149,4 +170,7 @@ export const make = Effect.gen(function* () { }); }); +/** + * Default live layer for ElectronTray. + */ export const layer = Layer.effect(ElectronTray, make); From 3187bc1981c5b86a5a4badb388cc78644232e491 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sat, 12 Sep 2026 00:54:56 +0800 Subject: [PATCH 5/7] fix(desktop): adapt TaggedError and DesktopWindow mock to latest main --- apps/desktop/src/app/DesktopTray.test.ts | 2 ++ apps/desktop/src/app/DesktopTray.ts | 2 +- apps/desktop/src/electron/ElectronTray.ts | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/DesktopTray.test.ts b/apps/desktop/src/app/DesktopTray.test.ts index 3a05398d955d..a69dc1f4a4a2 100644 --- a/apps/desktop/src/app/DesktopTray.test.ts +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -77,7 +77,9 @@ const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, + prepareCaptureReveal: Effect.void, dispatchMenuAction: () => Effect.void, + dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.void, syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts index fc6188cfbae0..d9694ecc6839 100644 --- a/apps/desktop/src/app/DesktopTray.ts +++ b/apps/desktop/src/app/DesktopTray.ts @@ -15,7 +15,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; /** * Error raised when configuring the high-level desktop system tray fails. */ -export class DesktopTrayConfigureError extends Schema.TaggedErrorClass()( +export class DesktopTrayConfigureError extends Schema.TaggedError()( "DesktopTrayConfigureError", { cause: Schema.Defect(), diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts index 36edd9dbd73c..f10be0cb6630 100644 --- a/apps/desktop/src/electron/ElectronTray.ts +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -11,7 +11,7 @@ import * as Electron from "electron"; /** * Error raised when creating the Electron system tray instance fails. */ -export class ElectronTrayCreateError extends Schema.TaggedErrorClass()( +export class ElectronTrayCreateError extends Schema.TaggedError()( "ElectronTrayCreateError", { iconPath: Schema.String, @@ -26,7 +26,7 @@ export class ElectronTrayCreateError extends Schema.TaggedErrorClass()( +export class ElectronTrayOperationError extends Schema.TaggedError()( "ElectronTrayOperationError", { operation: Schema.String, From 2bebe5a60e51b88012a6928828c77be605c446b1 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sat, 12 Sep 2026 01:28:21 +0800 Subject: [PATCH 6/7] fix(desktop): improve tray reveal reliability and destroy lifecycle - Invoke DesktopWindow.revealOrCreateMain on tray click and open actions so the window is revealed even during backend startup. - Retain ElectronTray reference when native destroy fails, allowing cleanup to be retried safely. - Reuse unified destroy helper during partial tray creation error handling. - Add and update unit tests for tray callbacks and error recovery. --- apps/desktop/src/app/DesktopTray.test.ts | 159 ++++++++++-------- apps/desktop/src/app/DesktopTray.ts | 6 +- .../desktop/src/electron/ElectronTray.test.ts | 28 +++ apps/desktop/src/electron/ElectronTray.ts | 87 +++++----- 4 files changed, 163 insertions(+), 117 deletions(-) diff --git a/apps/desktop/src/app/DesktopTray.test.ts b/apps/desktop/src/app/DesktopTray.test.ts index a69dc1f4a4a2..4fbccdaa47f9 100644 --- a/apps/desktop/src/app/DesktopTray.test.ts +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -44,78 +44,99 @@ const makeElectronTrayLayer = ( destroy: Effect.void, } satisfies ElectronTray.ElectronTray["Service"]); -const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { - metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("T3 Code"), - systemLocale: Effect.succeed("en-US"), - whenReady: Effect.void, - quit: Effect.void, - exit: () => Effect.void, - relaunch: () => Effect.void, - setPath: () => Effect.void, - setName: () => Effect.void, - setAboutPanelOptions: () => Effect.void, - setAppUserModelId: () => Effect.void, - getAppMetrics: Effect.succeed([]), - isDefaultProtocolClient: () => Effect.succeed(false), - setAsDefaultProtocolClient: () => Effect.succeed(true), - setDesktopName: () => Effect.void, - setDockIcon: () => Effect.void, - appendCommandLineSwitch: () => Effect.void, - onBeforeQuitForUpdate: () => Effect.void, - removeCommandLineSwitch: () => Effect.void, - on: () => Effect.void, -} satisfies ElectronApp.ElectronApp["Service"]); - -const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { - createMain: Effect.die("unexpected createMain"), - ensureMain: Effect.die("unexpected ensureMain"), - revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), - activate: Effect.void, - createMainIfBackendReady: Effect.void, - showConnectingSplash: Effect.void, - handleBackendReady: () => Effect.void, - handleBackendNotReady: Effect.void, - flushMainWindowBounds: Effect.void, - prepareCaptureReveal: Effect.void, - dispatchMenuAction: () => Effect.void, - dispatchSnapShotEvent: () => Effect.void, - zoomMain: () => Effect.void, - syncAppearance: Effect.void, -} satisfies DesktopWindow.DesktopWindow["Service"]); +const makeElectronAppLayer = (onQuit?: () => Effect.Effect) => + Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), + whenReady: Effect.void, + quit: onQuit ? onQuit() : Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + getAppMetrics: Effect.succeed([]), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, + on: () => Effect.void, + } satisfies ElectronApp.ElectronApp["Service"]); + +const makeDesktopWindowLayer = (onRevealOrCreateMain?: () => Effect.Effect) => + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: onRevealOrCreateMain ? onRevealOrCreateMain() : Effect.succeed({} as any), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + prepareCaptureReveal: Effect.void, + dispatchMenuAction: () => Effect.void, + dispatchSnapShotEvent: () => Effect.void, + zoomMain: () => Effect.void, + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindow["Service"]); describe("DesktopTray", () => { - it.effect("configures tray icon and menu on win32", () => - Effect.gen(function* () { - const createdOptions = yield* Deferred.make(); - const testLayer = DesktopTray.layer.pipe( - Layer.provideMerge(makeElectronTrayLayer(createdOptions)), - Layer.provideMerge(desktopAssetsLayer), - Layer.provideMerge(makeEnvironmentLayer("win32")), - Layer.provideMerge(electronAppLayer), - Layer.provideMerge(desktopWindowLayer), - Layer.provideMerge(NodeServices.layer), - ); + it.effect( + "configures tray icon and menu on win32 and invokes revealOrCreateMain on open actions", + () => + Effect.gen(function* () { + const createdOptions = yield* Deferred.make(); + const clickDeferred = yield* Deferred.make(); + const quitDeferred = yield* Deferred.make(); - yield* Effect.scoped( - Effect.gen(function* () { - const tray = yield* DesktopTray.DesktopTray; - assert.isFalse(yield* tray.isAvailable); + const testLayer = DesktopTray.layer.pipe( + Layer.provideMerge(makeElectronTrayLayer(createdOptions)), + Layer.provideMerge(desktopAssetsLayer), + Layer.provideMerge(makeEnvironmentLayer("win32")), + Layer.provideMerge( + makeElectronAppLayer(() => Deferred.succeed(quitDeferred, void 0).pipe(Effect.asVoid)), + ), + Layer.provideMerge( + makeDesktopWindowLayer(() => + Deferred.succeed(clickDeferred, void 0).pipe(Effect.asVoid), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); - yield* tray.configure; + yield* Effect.scoped( + Effect.gen(function* () { + const tray = yield* DesktopTray.DesktopTray; + assert.isFalse(yield* tray.isAvailable); - const options = yield* Deferred.await(createdOptions); - assert.equal(options.iconPath, "C:/path/to/icon.ico"); - assert.equal(options.tooltip, "T3 Code"); - assert.isDefined(options.menuItems); - const menuItems = options.menuItems ?? []; - assert.equal(menuItems.length, 3); - assert.equal(menuItems[0]?.label, "Open T3 Code"); - assert.equal(menuItems[2]?.label, "Quit T3 Code"); - assert.isTrue(yield* tray.isAvailable); - }), - ).pipe(Effect.provide(testLayer)); - }), + yield* tray.configure; + + const options = yield* Deferred.await(createdOptions); + assert.equal(options.iconPath, "C:/path/to/icon.ico"); + assert.equal(options.tooltip, "T3 Code"); + assert.isDefined(options.menuItems); + const menuItems = options.menuItems ?? []; + assert.equal(menuItems.length, 3); + assert.equal(menuItems[0]?.label, "Open T3 Code"); + assert.equal(menuItems[2]?.label, "Quit T3 Code"); + assert.isTrue(yield* tray.isAvailable); + + // Click on tray invokes revealOrCreateMain + options.onClick?.(); + yield* Deferred.await(clickDeferred); + + // Click on Quit invokes electronApp.quit + menuItems[2]?.click?.(); + yield* Deferred.await(quitDeferred); + }), + ).pipe(Effect.provide(testLayer)); + }), ); it.effect("skips configuring tray on darwin", () => @@ -125,8 +146,8 @@ describe("DesktopTray", () => { Layer.provideMerge(makeElectronTrayLayer(createdOptions)), Layer.provideMerge(desktopAssetsLayer), Layer.provideMerge(makeEnvironmentLayer("darwin")), - Layer.provideMerge(electronAppLayer), - Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(makeElectronAppLayer()), + Layer.provideMerge(makeDesktopWindowLayer()), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts index d9694ecc6839..881995d52d02 100644 --- a/apps/desktop/src/app/DesktopTray.ts +++ b/apps/desktop/src/app/DesktopTray.ts @@ -85,16 +85,16 @@ export const make = Effect.gen(function* () { iconPath, tooltip: environment.displayName, onClick: () => { - void runPromise(desktopWindow.activate); + void runPromise(desktopWindow.revealOrCreateMain); }, onDoubleClick: () => { - void runPromise(desktopWindow.activate); + void runPromise(desktopWindow.revealOrCreateMain); }, menuItems: [ { label: `Open ${environment.displayName}`, click: () => { - void runPromise(desktopWindow.activate); + void runPromise(desktopWindow.revealOrCreateMain); }, }, { diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts index 2df23c310d18..bac6f2056bdc 100644 --- a/apps/desktop/src/electron/ElectronTray.test.ts +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -174,4 +174,32 @@ describe("ElectronTray", () => { assert.isTrue(trayInstances[0]?.destroyed); }).pipe(Effect.provide(TestLayer)), ); + + it.effect("retains tray reference on failed destruction so cleanup can be retried", () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + + yield* electronTray.create({ iconPath: "icon1.ico" }); + const instance = trayInstances[0]; + assert.isDefined(instance); + + let shouldThrow = true; + const originalDestroy = instance.destroy; + instance.destroy = () => { + if (shouldThrow) { + throw new Error("Native destruction failure"); + } + originalDestroy.call(instance); + }; + + const failure = yield* electronTray.destroy.pipe(Effect.exit); + assert.isTrue(failure._tag === "Failure"); + assert.isFalse(instance.destroyed); + + // Subsequent destroy retry succeeds + shouldThrow = false; + yield* electronTray.destroy; + assert.isTrue(instance.destroyed); + }).pipe(Effect.provide(TestLayer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts index f10be0cb6630..f056919e84b9 100644 --- a/apps/desktop/src/electron/ElectronTray.ts +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -97,22 +97,26 @@ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const currentTrayRef = yield* Ref.make>(Option.none()); + const destroyTray = (tray: Electron.Tray) => + Effect.try({ + try: () => { + if (!tray.isDestroyed()) { + tray.destroy(); + } + }, + catch: (cause) => + new ElectronTrayOperationError({ + operation: "destroy-tray", + platform, + cause, + }), + }); + const destroy = Effect.gen(function* () { - const currentTray = yield* Ref.getAndSet(currentTrayRef, Option.none()); + const currentTray = yield* Ref.get(currentTrayRef); if (Option.isSome(currentTray)) { - yield* Effect.try({ - try: () => { - if (!currentTray.value.isDestroyed()) { - currentTray.value.destroy(); - } - }, - catch: (cause) => - new ElectronTrayOperationError({ - operation: "destroy-tray", - platform, - cause, - }), - }).pipe(Effect.orDie); + yield* destroyTray(currentTray.value).pipe(Effect.orDie); + yield* Ref.set(currentTrayRef, Option.none()); } }); @@ -120,47 +124,40 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { yield* destroy; + let partialTray: Electron.Tray | undefined; const tray = yield* Effect.try({ try: () => { - let partialTray: Electron.Tray | undefined; - try { - const icon = Electron.nativeImage.createFromPath(options.iconPath); - const newTray = new Electron.Tray(icon.isEmpty() ? options.iconPath : icon); - partialTray = newTray; - if (options.tooltip) { - newTray.setToolTip(options.tooltip); - } - if (options.menuItems && options.menuItems.length > 0) { - const template = mapMenuItems(options.menuItems); - const contextMenu = Electron.Menu.buildFromTemplate(template); - newTray.setContextMenu(contextMenu); - } - if (options.onClick) { - newTray.on("click", options.onClick); - } - if (options.onDoubleClick) { - newTray.on("double-click", options.onDoubleClick); - } - return newTray; - } catch (cause) { - if (partialTray && !partialTray.isDestroyed()) { - try { - partialTray.destroy(); - } catch { - // ignore - } - } - throw cause; + const icon = Electron.nativeImage.createFromPath(options.iconPath); + const newTray = new Electron.Tray(icon.isEmpty() ? options.iconPath : icon); + partialTray = newTray; + if (options.tooltip) { + newTray.setToolTip(options.tooltip); + } + if (options.menuItems && options.menuItems.length > 0) { + const template = mapMenuItems(options.menuItems); + const contextMenu = Electron.Menu.buildFromTemplate(template); + newTray.setContextMenu(contextMenu); + } + if (options.onClick) { + newTray.on("click", options.onClick); + } + if (options.onDoubleClick) { + newTray.on("double-click", options.onDoubleClick); } + return newTray; }, catch: (cause) => new ElectronTrayCreateError({ iconPath: options.iconPath, cause, }), - }); + }).pipe( + Effect.tap((createdTray) => Ref.set(currentTrayRef, Option.some(createdTray))), + Effect.tapError(() => + partialTray ? destroyTray(partialTray).pipe(Effect.orDie) : Effect.void, + ), + ); - yield* Ref.set(currentTrayRef, Option.some(tray)); return tray; }); From e8be38af698a138789eac3e22f3bd4f0a8fc0485 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sat, 12 Sep 2026 09:00:44 +0800 Subject: [PATCH 7/7] fix(desktop): preserve create error and retain partial tray on failed cleanup - Retain partialTray in currentTrayRef when creation cleanup fails so it can be retried later. - Avoid Effect.orDie during partial tray cleanup to preserve original ElectronTrayCreateError. - Add regression test for concurrent creation and destruction failures. --- .../desktop/src/electron/ElectronTray.test.ts | 41 +++++++++++++++++++ apps/desktop/src/electron/ElectronTray.ts | 11 +++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts index bac6f2056bdc..79daf8eb5c78 100644 --- a/apps/desktop/src/electron/ElectronTray.test.ts +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -202,4 +202,45 @@ describe("ElectronTray", () => { assert.isTrue(instance.destroyed); }).pipe(Effect.provide(TestLayer)), ); + + it.effect( + "retains partial tray in currentTrayRef and preserves create error when cleanup fails", + () => + Effect.gen(function* () { + const electronTray = yield* ElectronTray.ElectronTray; + + let shouldThrow = true; + buildFromTemplateMock.mockImplementationOnce(() => { + const instance = trayInstances[0]; + if (instance) { + const originalDestroy = instance.destroy; + instance.destroy = () => { + if (shouldThrow) { + throw new Error("Cleanup destruction failure"); + } + originalDestroy.call(instance); + }; + } + throw new Error("Menu template build failure"); + }); + + const result = yield* electronTray + .create({ + iconPath: "C:/path/to/icon.ico", + menuItems: [{ label: "Invalid Item" }], + }) + .pipe(Effect.flip); + + assert.instanceOf(result, ElectronTray.ElectronTrayCreateError); + assert.equal(trayInstances.length, 1); + const instance = trayInstances[0]; + assert.isDefined(instance); + assert.isFalse(instance.destroyed); + + // Subsequent destroy retry successfully cleans up the partial tray + shouldThrow = false; + yield* electronTray.destroy; + assert.isTrue(instance.destroyed); + }).pipe(Effect.provide(TestLayer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts index f056919e84b9..0a6ea8fb4ccc 100644 --- a/apps/desktop/src/electron/ElectronTray.ts +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -153,9 +153,14 @@ export const make = Effect.gen(function* () { }), }).pipe( Effect.tap((createdTray) => Ref.set(currentTrayRef, Option.some(createdTray))), - Effect.tapError(() => - partialTray ? destroyTray(partialTray).pipe(Effect.orDie) : Effect.void, - ), + Effect.tapError(() => { + const target = partialTray; + return target + ? destroyTray(target).pipe( + Effect.catch(() => Ref.set(currentTrayRef, Option.some(target))), + ) + : Effect.void; + }), ); return tray;