From ed597dfdd6bc64c535fa335e0a3ce8004624507a Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sun, 6 Sep 2026 00:43:29 +0800 Subject: [PATCH] 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),