diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e84e6d5c730d..e7e53ea4f9ca 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 DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; @@ -242,6 +243,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; @@ -298,6 +300,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 515be6726c94..f2b4e92732ba 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, @@ -122,6 +131,7 @@ describe("DesktopLifecycle", () => { ), ), Layer.provideMerge(makeDesktopWindowLayer()), + Layer.provideMerge(makeDesktopTrayLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), @@ -156,6 +166,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>(); @@ -194,6 +320,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), @@ -235,6 +362,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 924341781aa6..7897cfe7b6b5 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, @@ -190,6 +192,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; @@ -246,9 +249,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..4fbccdaa47f9 --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -0,0 +1,165 @@ +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 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 and invokes revealOrCreateMain on open actions", + () => + Effect.gen(function* () { + const createdOptions = yield* Deferred.make(); + const clickDeferred = yield* Deferred.make(); + const quitDeferred = yield* Deferred.make(); + + 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* 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); + + // 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", () => + 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(makeElectronAppLayer()), + Layer.provideMerge(makeDesktopWindowLayer()), + 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..881995d52d02 --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.ts @@ -0,0 +1,137 @@ +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"; + +/** + * Error raised when configuring the high-level desktop system tray fails. + */ +export class DesktopTrayConfigureError extends Schema.TaggedError()( + "DesktopTrayConfigureError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to configure desktop system tray."; + } +} + +/** + * High-level service managing tray lifecycle, platform check, and availability status. + */ +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"); + +/** + * Effect constructor creating the DesktopTray service. + */ +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.revealOrCreateMain); + }, + onDoubleClick: () => { + void runPromise(desktopWindow.revealOrCreateMain); + }, + menuItems: [ + { + label: `Open ${environment.displayName}`, + click: () => { + void runPromise(desktopWindow.revealOrCreateMain); + }, + }, + { + 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, + }); +}); + +/** + * Default live layer for DesktopTray. + */ +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 new file mode 100644 index 000000000000..79daf8eb5c78 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -0,0 +1,246 @@ +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", + type: "submenu", + submenu: [{ label: "Nested Action", 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); + + 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")?.(); + 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)), + ); + + 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)), + ); + + 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)), + ); + + 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 new file mode 100644 index 000000000000..0a6ea8fb4ccc --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -0,0 +1,178 @@ +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"; + +/** + * Error raised when creating the Electron system tray instance fails. + */ +export class ElectronTrayCreateError extends Schema.TaggedError()( + "ElectronTrayCreateError", + { + iconPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create Electron Tray with icon "${this.iconPath}".`; + } +} + +/** + * Error raised when performing an operation on the system tray fails. + */ +export class ElectronTrayOperationError extends Schema.TaggedError()( + "ElectronTrayOperationError", + { + operation: Schema.String, + platform: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron tray operation "${this.operation}" failed on ${this.platform}.`; + } +} + +/** + * Menu item configuration for constructing the tray context menu. + */ +export interface ElectronTrayMenuItem { + readonly label?: string; + readonly type?: "normal" | "separator" | "submenu" | "checkbox" | "radio"; + readonly click?: () => void; + readonly enabled?: boolean; + readonly submenu?: readonly ElectronTrayMenuItem[]; +} + +/** + * Options for creating an Electron system tray. + */ +export interface ElectronTrayCreateOptions { + readonly iconPath: string; + readonly tooltip?: string; + readonly menuItems?: readonly ElectronTrayMenuItem[]; + readonly onClick?: () => void; + readonly onDoubleClick?: () => void; +} + +/** + * Low-level service managing Electron's native system tray instance. + */ +export class ElectronTray extends Context.Service< + ElectronTray, + { + readonly create: ( + options: ElectronTrayCreateOptions, + ) => Effect.Effect; + readonly destroy: Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTray") {} + +/** + * Recursively maps typed menu item descriptors to Electron MenuItemConstructorOptions. + */ +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; + }); + +/** + * Effect constructor creating the ElectronTray service. + */ +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.get(currentTrayRef); + if (Option.isSome(currentTray)) { + yield* destroyTray(currentTray.value).pipe(Effect.orDie); + yield* Ref.set(currentTrayRef, Option.none()); + } + }); + + const create = (options: ElectronTrayCreateOptions) => + Effect.gen(function* () { + yield* destroy; + + let partialTray: Electron.Tray | undefined; + const tray = yield* Effect.try({ + 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) => + new ElectronTrayCreateError({ + iconPath: options.iconPath, + cause, + }), + }).pipe( + Effect.tap((createdTray) => Ref.set(currentTrayRef, Option.some(createdTray))), + Effect.tapError(() => { + const target = partialTray; + return target + ? destroyTray(target).pipe( + Effect.catch(() => Ref.set(currentTrayRef, Option.some(target))), + ) + : Effect.void; + }), + ); + + return tray; + }); + + return ElectronTray.of({ + create, + destroy, + }); +}); + +/** + * Default live layer for ElectronTray. + */ +export const layer = Layer.effect(ElectronTray, make); 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"]), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ed920abcdc8f..c2d79cd417ef 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"; @@ -58,6 +59,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"; @@ -127,6 +129,7 @@ const electronLayer = Layer.mergeAll( ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, + ElectronTray.layer, ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), @@ -201,6 +204,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, desktopAppActivationLayer, DesktopApplicationMenu.layer, + DesktopTray.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer,