Skip to content
Merged
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
266 changes: 266 additions & 0 deletions apps/desktop/src/electron/ElectronNotification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
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 Ref from "effect/Ref";
import type * as Electron from "electron";
import { beforeEach, vi } from "vite-plus/test";

interface FakeNotificationEntry {
readonly options: { readonly title?: string; readonly body?: string };
readonly listeners: Map<string, (...args: readonly unknown[]) => void>;
shown: number;
closed: number;
}

const { notificationState, FakeNotification } = vi.hoisted(() => {
const notificationState = {
supported: true,
constructorThrows: null as Error | null,
instances: [] as FakeNotificationEntry[],
};

class FakeNotification {
static isSupported(): boolean {
return notificationState.supported;
}

readonly entry: FakeNotificationEntry;

constructor(options: { title?: string; body?: string }) {
if (notificationState.constructorThrows !== null) {
throw notificationState.constructorThrows;
}
this.entry = { options, listeners: new Map(), shown: 0, closed: 0 };
notificationState.instances.push(this.entry);
}

on(event: string, listener: (...args: readonly unknown[]) => void): this {
this.entry.listeners.set(event, listener);
return this;
}

show(): void {
this.entry.shown += 1;
}

// Electron dismisses the notification and then emits `close`; the fake
// mirrors that so the service's bookkeeping is exercised.
close(): void {
this.entry.closed += 1;
this.entry.listeners.get("close")?.();
}
}

return { notificationState, FakeNotification };
});

vi.mock("electron", () => ({ Notification: FakeNotification }));

import { NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts";
import * as ElectronNotification from "./ElectronNotification.ts";
import * as ElectronWindow from "./ElectronWindow.ts";

const request = {
id: "thread-1",
title: "T3 Code needs your input",
body: "Claude is waiting on your answer.",
environmentId: "local",
threadId: "thread-1",
} as const;

const clickNotification = (index: number) => {
const entry = notificationState.instances[index];
assert.isDefined(entry);
const click = entry.listeners.get("click");
if (click === undefined) {
throw new Error("Expected the notification to register a click listener.");
}
click();
};

const clickFirstNotification = () => clickNotification(0);

const closeNotification = (index: number) => {
const entry = notificationState.instances[index];
assert.isDefined(entry);
const close = entry.listeners.get("close");
if (close === undefined) {
throw new Error("Expected the notification to register a close listener.");
}
close();
};

const silentWindowLayer = Layer.mock(ElectronWindow.ElectronWindow)({
focusedMainOrFirst: Effect.succeed(Option.none()),
sendAll: () => Effect.void,
});

describe("ElectronNotification", () => {
beforeEach(() => {
notificationState.supported = true;
notificationState.constructorThrows = null;
notificationState.instances = [];
});

it.effect("shows a native notification carrying the request title and body", () =>
Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
yield* notifications.show(request);

assert.equal(notificationState.instances.length, 1);
assert.deepEqual(notificationState.instances[0]?.options, {
title: "T3 Code needs your input",
body: "Claude is waiting on your answer.",
});
assert.equal(notificationState.instances[0]?.shown, 1);
}).pipe(
Effect.provide(
ElectronNotification.layer.pipe(
Layer.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
focusedMainOrFirst: Effect.succeed(Option.none()),
}),
),
),
),
),
);

it.effect("reveals the target window and broadcasts the activation on click", () =>
Effect.gen(function* () {
const window = { id: 7 } as Electron.BrowserWindow;
const revealedRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
const sent = yield* Deferred.make<readonly unknown[]>();
const windowLayer = Layer.mock(ElectronWindow.ElectronWindow)({
focusedMainOrFirst: Effect.succeed(Option.some(window)),
reveal: (target) => Ref.set(revealedRef, Option.some(target)),
sendAll: (channel, ...args) =>
Deferred.succeed(sent, [channel, ...args]).pipe(Effect.asVoid),
});

yield* Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
yield* notifications.show(request);

clickFirstNotification();

const broadcast = yield* Deferred.await(sent);
assert.deepEqual(broadcast, [
NOTIFICATION_ACTIVATED_CHANNEL,
{ environmentId: "local", threadId: "thread-1" },
]);
assert.strictEqual(Option.getOrNull(yield* Ref.get(revealedRef)), window);
}).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(windowLayer))));
}),
);

it.effect("broadcasts the activation even when there is no window to reveal", () =>
Effect.gen(function* () {
const sent = yield* Deferred.make<readonly unknown[]>();
const windowLayer = Layer.mock(ElectronWindow.ElectronWindow)({
focusedMainOrFirst: Effect.succeed(Option.none()),
sendAll: (channel, ...args) =>
Deferred.succeed(sent, [channel, ...args]).pipe(Effect.asVoid),
});

yield* Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
yield* notifications.show(request);

clickFirstNotification();

assert.deepEqual(yield* Deferred.await(sent), [
NOTIFICATION_ACTIVATED_CHANNEL,
{ environmentId: "local", threadId: "thread-1" },
]);
}).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(windowLayer))));
}),
);

it.effect("no-ops when the platform does not support native notifications", () =>
Effect.gen(function* () {
notificationState.supported = false;

const notifications = yield* ElectronNotification.ElectronNotification;
yield* notifications.show(request);

assert.equal(notificationState.instances.length, 0);
}).pipe(
Effect.provide(
ElectronNotification.layer.pipe(
Layer.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
focusedMainOrFirst: Effect.succeed(Option.none()),
}),
),
),
),
),
);

it.effect("closes the previous notification when the same id is shown again", () =>
Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
yield* notifications.show(request);
yield* notifications.show(request);
yield* notifications.show({ ...request, id: "thread-2", threadId: "thread-2" });

assert.equal(notificationState.instances.length, 3);
// Only the first is superseded: the third carries a different id.
assert.equal(notificationState.instances[0]?.closed, 1);
assert.equal(notificationState.instances[1]?.closed, 0);
assert.equal(notificationState.instances[2]?.closed, 0);
}).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))),
);

it.effect("forgets a dismissed notification so it is never closed twice", () =>
Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
yield* notifications.show(request);

// The OS dismissal emits `close` without routing through `close()`.
closeNotification(0);
yield* notifications.show(request);

assert.equal(notificationState.instances.length, 2);
assert.equal(notificationState.instances[0]?.closed, 0);
}).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))),
);

it.effect("forgets a clicked notification so it is never closed afterwards", () =>
Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
yield* notifications.show(request);

clickFirstNotification();
yield* notifications.show(request);

assert.equal(notificationState.instances.length, 2);
assert.equal(notificationState.instances[0]?.closed, 0);
}).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))),
);

it.effect("swallows Electron notification failures instead of failing the caller", () =>
Effect.gen(function* () {
notificationState.constructorThrows = new Error("notification center unavailable");

const notifications = yield* ElectronNotification.ElectronNotification;
const exit = yield* Effect.exit(notifications.show(request));

assert.equal(exit._tag, "Success");
assert.equal(notificationState.instances.length, 0);
}).pipe(
Effect.provide(
ElectronNotification.layer.pipe(
Layer.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
focusedMainOrFirst: Effect.succeed(Option.none()),
}),
),
),
),
),
);
});
113 changes: 113 additions & 0 deletions apps/desktop/src/electron/ElectronNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { DesktopNotificationActivation, DesktopNotificationRequest } from "@t3tools/contracts";
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 Schema from "effect/Schema";

import * as Electron from "electron";

import { NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts";
import * as ElectronWindow from "./ElectronWindow.ts";

export class ElectronNotificationShowError extends Schema.TaggedErrorClass<ElectronNotificationShowError>()(
"ElectronNotificationShowError",
{
notificationId: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to show the Electron notification ${JSON.stringify(this.notificationId)}.`;
}
}

export class ElectronNotification extends Context.Service<
ElectronNotification,
{
/**
* Raises a native OS notification. Resolves once the notification has been
* handed to the platform, and no-ops when the platform has no notification
* support. Clicking the notification reveals the current window and
* broadcasts a `DesktopNotificationActivation` to every renderer.
*/
readonly show: (request: DesktopNotificationRequest) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronNotification") {}

export const make = Effect.gen(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
// The click listener is a bare Electron callback, so the activation effect
// has to be run against the captured context (keeps logging/tracing intact).
const context = yield* Effect.context<never>();
const runFork = Effect.runForkWith(context);

// Live notifications keyed by `DesktopNotificationRequest.id`, which the
// contract defines as a coalescing key: showing again for the same id
// replaces the previous entry instead of stacking a second one in
// Notification Center (the web path gets this from the notification `tag`).
// Holding the JS reference also keeps the notification from being collected
// while it is on screen, which is what makes Electron's `click` reliable.
const liveNotifications = new Map<string, Electron.Notification>();

const activate = Effect.fn("desktop.electron.notification.activate")(function* (
request: DesktopNotificationRequest,
) {
const window = yield* electronWindow.focusedMainOrFirst;
if (Option.isSome(window)) {
yield* electronWindow.reveal(window.value);
}

const activation: DesktopNotificationActivation = {
environmentId: request.environmentId,
threadId: request.threadId,
};
// Broadcast rather than target the revealed window: any renderer may own
// the thread, and the activation is idempotent for the ones that don't.
yield* electronWindow.sendAll(NOTIFICATION_ACTIVATED_CHANNEL, activation);
});

const show = Effect.fn("desktop.electron.notification.show")(function* (
request: DesktopNotificationRequest,
) {
const supported = yield* Effect.try({
try: () => Electron.Notification.isSupported(),
catch: (cause) => new ElectronNotificationShowError({ notificationId: request.id, cause }),
});
if (!supported) {
return;
}

yield* Effect.try({
try: () => {
const notification = new Electron.Notification({
title: request.title,
body: request.body,
});
const forget = () => {
if (liveNotifications.get(request.id) === notification) {
liveNotifications.delete(request.id);
}
};
notification.on("click", () => {
forget();
runFork(activate(request));
});
notification.on("close", forget);

liveNotifications.get(request.id)?.close();
liveNotifications.set(request.id, notification);
notification.show();
},
catch: (cause) => new ElectronNotificationShowError({ notificationId: request.id, cause }),
});
});

return ElectronNotification.of({
// Notifications are advisory: failing to raise one must never fail the
// renderer's invoke, so failures are logged and swallowed here.
show: (request) => show(request).pipe(Effect.catch((error) => Effect.logWarning(error))),
});
});

export const layer = Layer.effect(ElectronNotification, make);
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
issueSshWebSocketTicket,
resolveSshPasswordPrompt,
} from "./methods/sshEnvironment.ts";
import { showNotification } from "./methods/notifications.ts";
import {
checkForUpdate,
downloadUpdate,
Expand Down Expand Up @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(showNotification);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
yield* ipc.handle(downloadUpdate);
Expand Down
Loading