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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/desktop/src/electron/ElectronTray.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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)),
);
});
134 changes: 134 additions & 0 deletions apps/desktop/src/electron/ElectronTray.ts
Original file line number Diff line number Diff line change
@@ -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>()(
"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>()(
"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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟑 Medium electron/ElectronTray.ts:38

ElectronTrayMenuItem advertises type: "submenu", but callers cannot provide any submenu entries, so selecting that type creates an empty submenu and nested tray actions cannot be implemented. Add a recursive submenu/children field and map it to MenuItemConstructorOptions.submenu when building the template.

πŸ€– Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/electron/ElectronTray.ts around line 38:

`ElectronTrayMenuItem` advertises `type: "submenu"`, but callers cannot provide any submenu entries, so selecting that type creates an empty submenu and nested tray actions cannot be implemented. Add a recursive `submenu`/children field and map it to `MenuItemConstructorOptions.submenu` when building the template.

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<Electron.Tray, ElectronTrayCreateError>;
readonly destroy: Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronTray") {}

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
const currentTrayRef = yield* Ref.make<Option.Option<Electron.Tray>>(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);
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -126,6 +127,7 @@ const electronLayer = Layer.mergeAll(
ElectronSafeStorage.layer,
ElectronShell.layer,
ElectronTheme.layer,
ElectronTray.layer,
ElectronUpdater.layer,
ElectronWindow.layer,
DesktopIpc.layer(Electron.ipcMain),
Expand Down
Loading