Skip to content

Commit ed597df

Browse files
committed
feat(desktop): add ElectronTray service
1 parent 761d4ba commit ed597df

3 files changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { assert, describe, it } from "@effect/vitest";
2+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3+
import * as Effect from "effect/Effect";
4+
import * as Layer from "effect/Layer";
5+
import type * as Electron from "electron";
6+
import { beforeEach, vi } from "vite-plus/test";
7+
8+
interface MockTrayInstance {
9+
icon: unknown;
10+
toolTip?: string;
11+
contextMenu?: unknown;
12+
listeners: Map<string, (...args: unknown[]) => void>;
13+
destroyed: boolean;
14+
destroy: () => void;
15+
isDestroyed: () => boolean;
16+
setToolTip: (tip: string) => void;
17+
setContextMenu: (menu: unknown) => void;
18+
on: (event: string, listener: (...args: unknown[]) => void) => void;
19+
}
20+
21+
const { buildFromTemplateMock, createFromPathMock, trayInstances } = vi.hoisted(() => {
22+
const instances: MockTrayInstance[] = [];
23+
24+
return {
25+
buildFromTemplateMock: vi.fn((template) => ({ template })),
26+
createFromPathMock: vi.fn((path: string) => ({
27+
isEmpty: () => false,
28+
path,
29+
})),
30+
trayInstances: instances,
31+
};
32+
});
33+
34+
vi.mock("electron", () => {
35+
class MockTray {
36+
toolTip?: string;
37+
contextMenu?: unknown;
38+
listeners = new Map<string, (...args: unknown[]) => void>();
39+
destroyed = false;
40+
icon: unknown;
41+
42+
constructor(icon: unknown) {
43+
this.icon = icon;
44+
trayInstances.push(this);
45+
}
46+
47+
setToolTip(tip: string) {
48+
this.toolTip = tip;
49+
}
50+
51+
setContextMenu(menu: unknown) {
52+
this.contextMenu = menu;
53+
}
54+
55+
on(event: string, listener: (...args: unknown[]) => void) {
56+
this.listeners.set(event, listener);
57+
}
58+
59+
destroy() {
60+
this.destroyed = true;
61+
}
62+
63+
isDestroyed() {
64+
return this.destroyed;
65+
}
66+
}
67+
68+
return {
69+
Menu: {
70+
buildFromTemplate: buildFromTemplateMock,
71+
},
72+
nativeImage: {
73+
createFromPath: createFromPathMock,
74+
},
75+
Tray: MockTray,
76+
};
77+
});
78+
79+
import * as ElectronTray from "./ElectronTray.ts";
80+
81+
const TestLayer = ElectronTray.layer.pipe(
82+
Layer.provide(Layer.succeed(HostProcessPlatform, "win32")),
83+
);
84+
85+
describe("ElectronTray", () => {
86+
beforeEach(() => {
87+
buildFromTemplateMock.mockClear();
88+
createFromPathMock.mockClear();
89+
trayInstances.length = 0;
90+
});
91+
92+
it.effect("creates tray with tooltip and context menu and click listeners", () =>
93+
Effect.gen(function* () {
94+
const electronTray = yield* ElectronTray.ElectronTray;
95+
let clicked = false;
96+
let doubleClicked = false;
97+
98+
yield* electronTray.create({
99+
iconPath: "C:/path/to/icon.ico",
100+
tooltip: "T3 Code",
101+
onClick: () => {
102+
clicked = true;
103+
},
104+
onDoubleClick: () => {
105+
doubleClicked = true;
106+
},
107+
menuItems: [
108+
{ label: "Open T3 Code", click: () => {} },
109+
{ type: "separator" },
110+
{ label: "Quit", click: () => {} },
111+
],
112+
});
113+
114+
assert.equal(trayInstances.length, 1);
115+
const instance = trayInstances[0];
116+
assert.isDefined(instance);
117+
assert.equal(instance?.toolTip, "T3 Code");
118+
assert.isTrue(instance?.listeners.has("click"));
119+
assert.isTrue(instance?.listeners.has("double-click"));
120+
assert.equal(buildFromTemplateMock.mock.calls.length, 1);
121+
122+
instance?.listeners.get("click")?.();
123+
assert.isTrue(clicked);
124+
instance?.listeners.get("double-click")?.();
125+
assert.isTrue(doubleClicked);
126+
127+
yield* electronTray.destroy;
128+
assert.isTrue(instance?.destroyed);
129+
}).pipe(Effect.provide(TestLayer)),
130+
);
131+
132+
it.effect("destroys old tray before creating a new one", () =>
133+
Effect.gen(function* () {
134+
const electronTray = yield* ElectronTray.ElectronTray;
135+
136+
yield* electronTray.create({ iconPath: "icon1.ico" });
137+
assert.equal(trayInstances.length, 1);
138+
assert.isFalse(trayInstances[0]?.destroyed);
139+
140+
yield* electronTray.create({ iconPath: "icon2.ico" });
141+
assert.equal(trayInstances.length, 2);
142+
assert.isTrue(trayInstances[0]?.destroyed);
143+
assert.isFalse(trayInstances[1]?.destroyed);
144+
}).pipe(Effect.provide(TestLayer)),
145+
);
146+
});
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2+
import * as Context from "effect/Context";
3+
import * as Effect from "effect/Effect";
4+
import * as Layer from "effect/Layer";
5+
import * as Option from "effect/Option";
6+
import * as Ref from "effect/Ref";
7+
import * as Schema from "effect/Schema";
8+
9+
import * as Electron from "electron";
10+
11+
export class ElectronTrayCreateError extends Schema.TaggedErrorClass<ElectronTrayCreateError>()(
12+
"ElectronTrayCreateError",
13+
{
14+
iconPath: Schema.String,
15+
cause: Schema.Defect(),
16+
},
17+
) {
18+
override get message(): string {
19+
return `Failed to create Electron Tray with icon "${this.iconPath}".`;
20+
}
21+
}
22+
23+
export class ElectronTrayOperationError extends Schema.TaggedErrorClass<ElectronTrayOperationError>()(
24+
"ElectronTrayOperationError",
25+
{
26+
operation: Schema.String,
27+
platform: Schema.String,
28+
cause: Schema.Defect(),
29+
},
30+
) {
31+
override get message(): string {
32+
return `Electron tray operation "${this.operation}" failed on ${this.platform}.`;
33+
}
34+
}
35+
36+
export interface ElectronTrayMenuItem {
37+
readonly label?: string;
38+
readonly type?: "normal" | "separator" | "submenu" | "checkbox" | "radio";
39+
readonly click?: () => void;
40+
readonly enabled?: boolean;
41+
}
42+
43+
export interface ElectronTrayCreateOptions {
44+
readonly iconPath: string;
45+
readonly tooltip?: string;
46+
readonly menuItems?: readonly ElectronTrayMenuItem[];
47+
readonly onClick?: () => void;
48+
readonly onDoubleClick?: () => void;
49+
}
50+
51+
export class ElectronTray extends Context.Service<
52+
ElectronTray,
53+
{
54+
readonly create: (
55+
options: ElectronTrayCreateOptions,
56+
) => Effect.Effect<Electron.Tray, ElectronTrayCreateError>;
57+
readonly destroy: Effect.Effect<void>;
58+
}
59+
>()("@t3tools/desktop/electron/ElectronTray") {}
60+
61+
export const make = Effect.gen(function* () {
62+
const platform = yield* HostProcessPlatform;
63+
const currentTrayRef = yield* Ref.make<Option.Option<Electron.Tray>>(Option.none());
64+
65+
const destroy = Effect.gen(function* () {
66+
const currentTray = yield* Ref.getAndSet(currentTrayRef, Option.none());
67+
if (Option.isSome(currentTray)) {
68+
yield* Effect.try({
69+
try: () => {
70+
if (!currentTray.value.isDestroyed()) {
71+
currentTray.value.destroy();
72+
}
73+
},
74+
catch: (cause) =>
75+
new ElectronTrayOperationError({
76+
operation: "destroy-tray",
77+
platform,
78+
cause,
79+
}),
80+
}).pipe(Effect.orDie);
81+
}
82+
});
83+
84+
const create = (options: ElectronTrayCreateOptions) =>
85+
Effect.gen(function* () {
86+
yield* destroy;
87+
88+
const tray = yield* Effect.try({
89+
try: () => {
90+
const icon = Electron.nativeImage.createFromPath(options.iconPath);
91+
const newTray = new Electron.Tray(icon.isEmpty() ? options.iconPath : icon);
92+
if (options.tooltip) {
93+
newTray.setToolTip(options.tooltip);
94+
}
95+
if (options.menuItems && options.menuItems.length > 0) {
96+
const template: Electron.MenuItemConstructorOptions[] = options.menuItems.map(
97+
(item) => {
98+
const menuItem: Electron.MenuItemConstructorOptions = {};
99+
if (item.label !== undefined) menuItem.label = item.label;
100+
if (item.type !== undefined) menuItem.type = item.type;
101+
if (item.click !== undefined) menuItem.click = item.click;
102+
if (item.enabled !== undefined) menuItem.enabled = item.enabled;
103+
return menuItem;
104+
},
105+
);
106+
const contextMenu = Electron.Menu.buildFromTemplate(template);
107+
newTray.setContextMenu(contextMenu);
108+
}
109+
if (options.onClick) {
110+
newTray.on("click", options.onClick);
111+
}
112+
if (options.onDoubleClick) {
113+
newTray.on("double-click", options.onDoubleClick);
114+
}
115+
return newTray;
116+
},
117+
catch: (cause) =>
118+
new ElectronTrayCreateError({
119+
iconPath: options.iconPath,
120+
cause,
121+
}),
122+
});
123+
124+
yield* Ref.set(currentTrayRef, Option.some(tray));
125+
return tray;
126+
});
127+
128+
return ElectronTray.of({
129+
create,
130+
destroy,
131+
});
132+
});
133+
134+
export const layer = Layer.effect(ElectronTray, make);

‎apps/desktop/src/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
2929
import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts";
3030
import * as ElectronShell from "./electron/ElectronShell.ts";
3131
import * as ElectronTheme from "./electron/ElectronTheme.ts";
32+
import * as ElectronTray from "./electron/ElectronTray.ts";
3233
import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
3334
import * as ElectronWindow from "./electron/ElectronWindow.ts";
3435
import * as DesktopApp from "./app/DesktopApp.ts";
@@ -126,6 +127,7 @@ const electronLayer = Layer.mergeAll(
126127
ElectronSafeStorage.layer,
127128
ElectronShell.layer,
128129
ElectronTheme.layer,
130+
ElectronTray.layer,
129131
ElectronUpdater.layer,
130132
ElectronWindow.layer,
131133
DesktopIpc.layer(Electron.ipcMain),

0 commit comments

Comments
 (0)