Skip to content
Open
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
Binary file added apps/desktop/resources/t3TrayTemplate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/t3TrayTemplate@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopTray from "../window/DesktopTray.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
Expand Down Expand Up @@ -221,6 +222,7 @@ const bootstrap = Effect.gen(function* () {
const startup = Effect.gen(function* () {
const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity;
const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
const tray = yield* DesktopTray.DesktopTray;
const electronApp = yield* ElectronApp.ElectronApp;
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
Expand Down Expand Up @@ -285,6 +287,7 @@ const startup = Effect.gen(function* () {
}
yield* appIdentity.configure;
yield* applicationMenu.configure;
yield* tray.configure;
yield* updates.configure;
yield* linuxUrlHandler.register;
yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
Expand Down
117 changes: 117 additions & 0 deletions apps/desktop/src/electron/ElectronTray.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 { beforeEach, vi } from "vite-plus/test";

const { buildFromTemplateMock, createFromPathMock, trayInstances, TrayMock } = vi.hoisted(() => {
class FakeTray {
readonly listeners = new Map<string, () => void>();
readonly icon: unknown;
tooltip: string | null = null;
destroyed = false;
poppedMenus: unknown[] = [];
constructor(icon: unknown) {
this.icon = icon;
trayInstances.push(this);
}
on(event: string, listener: () => void) {
this.listeners.set(event, listener);
return this;
}
setToolTip(tooltip: string) {
this.tooltip = tooltip;
}
popUpContextMenu(menu: unknown) {
this.poppedMenus.push(menu);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
}
}
const trayInstances: FakeTray[] = [];
return {
buildFromTemplateMock: vi.fn((template: unknown) => ({ template })),
createFromPathMock: vi.fn((path: string) => ({
path,
templateImage: false,
setTemplateImage(value: boolean) {
this.templateImage = value;
},
})),
trayInstances,
TrayMock: FakeTray,
};
});

vi.mock("electron", () => ({
Tray: TrayMock,
Menu: {
buildFromTemplate: buildFromTemplateMock,
},
nativeImage: {
createFromPath: createFromPathMock,
},
}));

import * as ElectronTray from "./ElectronTray.ts";

const TestLayer = ElectronTray.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")),
);

describe("ElectronTray", () => {
beforeEach(() => {
buildFromTemplateMock.mockClear();
createFromPathMock.mockClear();
trayInstances.length = 0;
});

it.effect("creates a template-image tray and destroys it with the scope", () =>
Effect.gen(function* () {
const electronTray = yield* ElectronTray.ElectronTray;
const onClick = vi.fn();
yield* Effect.scoped(
Effect.gen(function* () {
yield* electronTray.create({ iconPath: "/tray.png", tooltip: "T3 Code", onClick });
const tray = trayInstances[0];
assert.isDefined(tray);
assert.equal(tray?.tooltip, "T3 Code");
assert.isTrue((tray?.icon as { templateImage: boolean } | undefined)?.templateImage);
tray?.listeners.get("click")?.();
tray?.listeners.get("right-click")?.();
assert.equal(onClick.mock.calls.length, 2);
assert.isFalse(tray?.destroyed);
}),
);
assert.isTrue(trayInstances[0]?.destroyed);
}).pipe(Effect.provide(TestLayer)),
);

it.effect("pops up a menu built from the template on the live tray", () =>
Effect.gen(function* () {
const electronTray = yield* ElectronTray.ElectronTray;
yield* Effect.scoped(
Effect.gen(function* () {
yield* electronTray.create({ iconPath: "/tray.png", tooltip: "T3 Code", onClick: () => {} });
yield* electronTray.popUpMenu([{ label: "Open" }]);
const tray = trayInstances[0];
assert.equal(buildFromTemplateMock.mock.calls.length, 1);
assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0], [{ label: "Open" }]);
assert.equal(tray?.poppedMenus.length, 1);
}),
);
}).pipe(Effect.provide(TestLayer)),
);

it.effect("ignores popUpMenu when no tray exists", () =>
Effect.gen(function* () {
const electronTray = yield* ElectronTray.ElectronTray;
yield* electronTray.popUpMenu([{ label: "Open" }]);
assert.equal(buildFromTemplateMock.mock.calls.length, 0);
}).pipe(Effect.provide(TestLayer)),
);
});
95 changes: 95 additions & 0 deletions apps/desktop/src/electron/ElectronTray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 Schema from "effect/Schema";
import type * as Scope from "effect/Scope";

import * as Electron from "electron";

export interface ElectronTrayCreateInput {
readonly iconPath: string;
readonly tooltip: string;
/**
* Fired on both left and right click. The tray deliberately has no
* persistent context menu: with one set, macOS opens it on mousedown and
* never emits `click`, which would prevent building the menu from fresh
* data at open time. Callers respond by popping a menu via `popUpMenu`.
*/
readonly onClick: () => void;
}

const ElectronTrayOperation = Schema.Literals(["create", "pop-up-menu"]);

export class ElectronTrayOperationError extends Schema.TaggedErrorClass<ElectronTrayOperationError>()(
"ElectronTrayOperationError",
{
operation: ElectronTrayOperation,
platform: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Electron tray operation ${JSON.stringify(this.operation)} failed on ${this.platform}.`;
}
}

export class ElectronTray extends Context.Service<
ElectronTray,
{
readonly create: (
input: ElectronTrayCreateInput,
) => Effect.Effect<void, ElectronTrayOperationError, Scope.Scope>;
readonly popUpMenu: (
template: readonly Electron.MenuItemConstructorOptions[],
) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronTray") {}

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
// The single live tray instance; Electron's Tray must be kept referenced
// or GC destroys the menu bar item silently.
let currentTray: Electron.Tray | null = null;

return ElectronTray.of({
create: (input) =>
Effect.acquireRelease(
Effect.try({
try: () => {
const icon = Electron.nativeImage.createFromPath(input.iconPath);
// The `Template` filename suffix already marks the image, but the
// explicit call keeps the behavior when the asset gets renamed.
icon.setTemplateImage(true);
const tray = new Electron.Tray(icon);
tray.setToolTip(input.tooltip);
tray.on("click", input.onClick);
tray.on("right-click", input.onClick);
return tray;
},
catch: (cause) =>
new ElectronTrayOperationError({ operation: "create", platform, cause }),
}).pipe(Effect.tap((tray) => Effect.sync(() => (currentTray = tray)))),
(tray) =>
Effect.sync(() => {
if (currentTray === tray) {
currentTray = null;
}
tray.destroy();
}),
).pipe(Effect.asVoid),
popUpMenu: (template) =>
Effect.try({
try: () => {
if (currentTray === null || currentTray.isDestroyed()) {
return;
}
currentTray.popUpContextMenu(Electron.Menu.buildFromTemplate([...template]));
},
catch: (cause) =>
new ElectronTrayOperationError({ operation: "pop-up-menu", platform, cause }),
}).pipe(Effect.orDie),
});
});

export const layer = Layer.effect(ElectronTray, make);
4 changes: 4 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ 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";
import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
import * as DesktopClerk from "./app/DesktopClerk.ts";
import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
import * as DesktopTray from "./window/DesktopTray.ts";
import * as DesktopAssets from "./app/DesktopAssets.ts";
import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts";
Expand Down Expand Up @@ -122,6 +124,7 @@ const electronLayer = Layer.mergeAll(
ElectronSafeStorage.layer,
ElectronShell.layer,
ElectronTheme.layer,
ElectronTray.layer,
ElectronUpdater.layer,
ElectronWindow.layer,
DesktopIpc.layer(Electron.ipcMain),
Expand Down Expand Up @@ -183,6 +186,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe(
const desktopApplicationLayer = Layer.mergeAll(
DesktopLifecycle.layer,
DesktopApplicationMenu.layer,
DesktopTray.layer,
DesktopLinuxUrlHandler.layer,
DesktopShellEnvironment.layer,
desktopSshLayer,
Expand Down
56 changes: 31 additions & 25 deletions apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
yield* desktopWindow.zoomMain(direction);
});

const checkForUpdatesFromMenu = Effect.gen(function* () {
const checkForUpdatesFromMenu = Effect.fn("desktop.menu.checkForUpdates")(function* (
reason: string,
) {
const updates = yield* DesktopUpdates.DesktopUpdates;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const result = yield* updates.check("menu");
const result = yield* updates.check(reason);
const updateState = result.state;

if (updateState.status === "up-to-date") {
Expand All @@ -78,30 +80,34 @@ const checkForUpdatesFromMenu = Effect.gen(function* () {
buttons: ["OK"],
});
}
}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));
});

const handleCheckForUpdatesMenuClick = Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const disabledReason = yield* updates.disabledReason;
if (Option.isSome(disabledReason)) {
yield* logUpdaterInfo("manual update check requested, but updates are disabled", {
disabledReason: disabledReason.value,
});
yield* electronDialog.showMessageBox({
type: "info",
title: "Updates unavailable",
message: "Automatic updates are not available right now.",
detail: disabledReason.value,
buttons: ["OK"],
});
return;
}
// Shared by the application menu and the tray menu; `reason` tags the
// update check's telemetry with which surface triggered it.
export const handleCheckForUpdatesClick = Effect.fn("desktop.menu.handleCheckForUpdatesClick")(
function* (reason: string) {
const updates = yield* DesktopUpdates.DesktopUpdates;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const disabledReason = yield* updates.disabledReason;
if (Option.isSome(disabledReason)) {
yield* logUpdaterInfo("manual update check requested, but updates are disabled", {
disabledReason: disabledReason.value,
});
yield* electronDialog.showMessageBox({
type: "info",
title: "Updates unavailable",
message: "Automatic updates are not available right now.",
detail: disabledReason.value,
buttons: ["OK"],
});
return;
}

const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.ensureMain;
yield* checkForUpdatesFromMenu;
}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick"));
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.ensureMain;
yield* checkForUpdatesFromMenu(reason);
},
);

export const make = Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
Expand Down Expand Up @@ -129,7 +135,7 @@ export const make = Effect.gen(function* () {

const configure = Effect.gen(function* () {
const checkForUpdatesClick = () => {
runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick);
runMenuEffect("check-for-updates", handleCheckForUpdatesClick("menu"));
};
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
Expand Down
Loading
Loading