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
11 changes: 10 additions & 1 deletion apps/desktop/src/app/DesktopConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ export const DesktopConfig = Config.all({
Config.withDefault(10_000),
),
appImagePath: trimmedString("APPIMAGE"),
disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),
// Auto-updates are off in this fork. It is built locally and has no update
// feed of its own, so every prompt would point at an upstream release that
// lacks these changes and would overwrite them on install. Inverted from
// upstream's opt-in: absent env means disabled, and only an explicit
// T3CODE_DISABLE_AUTO_UPDATE=false turns the updater back on (which the
// updater's own tests rely on to exercise the real flow).
disableAutoUpdate: Config.boolean("T3CODE_DISABLE_AUTO_UPDATE").pipe(
Config.option,
Config.map(Option.getOrElse(() => true)),
),
mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),
mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(
Config.withDefault(3000),
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/electron/ElectronNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { beforeEach, vi } from "vite-plus/test";

const { isSupportedMock, showMock, notificationConstructorMock, handlersByEvent } = vi.hoisted(
() => ({
isSupportedMock: vi.fn(),
showMock: vi.fn(),
notificationConstructorMock: vi.fn(),
handlersByEvent: new Map<string, () => void>(),
}),
);

vi.mock("electron", () => {
class Notification {
static isSupported = isSupportedMock;

constructor(options: unknown) {
notificationConstructorMock(options);
}

on(event: string, handler: () => void) {
handlersByEvent.set(event, handler);
return this;
}

show() {
showMock();
}
}

return { Notification };
});

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

describe("ElectronNotifications", () => {
beforeEach(() => {
isSupportedMock.mockReset();
showMock.mockReset();
notificationConstructorMock.mockReset();
handlersByEvent.clear();
isSupportedMock.mockReturnValue(true);
});

it.effect("shows a notification with the requested copy", () =>
Effect.gen(function* () {
const notifications = yield* ElectronNotifications.ElectronNotifications;
yield* notifications.show({
title: "Fix flaky auth test",
body: "Agent finished · t3code",
silent: true,
onActivate: () => {},
});

assert.deepEqual(notificationConstructorMock.mock.calls, [
[{ title: "Fix flaky auth test", body: "Agent finished · t3code", silent: true }],
]);
assert.equal(showMock.mock.calls.length, 1);
}).pipe(Effect.provide(ElectronNotifications.layer)),
);

it.effect("does nothing when the platform has no notification support", () =>
Effect.gen(function* () {
isSupportedMock.mockReturnValue(false);

const notifications = yield* ElectronNotifications.ElectronNotifications;
yield* notifications.show({
title: "Fix flaky auth test",
body: "Agent finished",
silent: false,
onActivate: () => {},
});

assert.equal(notificationConstructorMock.mock.calls.length, 0);
assert.equal(showMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronNotifications.layer)),
);

it.effect("invokes onActivate when the notification is clicked", () =>
Effect.gen(function* () {
let activated = 0;

const notifications = yield* ElectronNotifications.ElectronNotifications;
yield* notifications.show({
title: "Fix flaky auth test",
body: "Agent finished",
silent: false,
onActivate: () => {
activated += 1;
},
});

handlersByEvent.get("click")?.();
assert.equal(activated, 1);
}).pipe(Effect.provide(ElectronNotifications.layer)),
);

it.effect("survives a construction failure instead of taking the app down", () =>
Effect.gen(function* () {
isSupportedMock.mockImplementation(() => {
throw new Error("notification center unavailable");
});

const notifications = yield* ElectronNotifications.ElectronNotifications;
yield* notifications.show({
title: "Fix flaky auth test",
body: "Agent finished",
silent: false,
onActivate: () => {},
});

assert.equal(showMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronNotifications.layer)),
);
});
68 changes: 68 additions & 0 deletions apps/desktop/src/electron/ElectronNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as Electron from "electron";

import { makeComponentLogger } from "../app/DesktopObservability.ts";

const { logWarning: logNotificationWarning } = makeComponentLogger("desktop-notifications");

export interface ShowNotificationInput {
readonly title: string;
readonly body: string;
readonly silent: boolean;
readonly onActivate: () => void;
}

export class ElectronNotifications extends Context.Service<
ElectronNotifications,
{
/**
* Raises a native OS notification. Never fails: a notification that cannot
* be shown is a cosmetic loss, and taking the app down over one would be
* strictly worse than staying quiet.
*/
readonly show: (input: ShowNotificationInput) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronNotifications") {}

/**
* Notifications are handed to the OS asynchronously. Dropping the last JS
* reference before the platform is done with it lets GC collect the object
* mid-flight, which silently loses the banner (and its click handler) on both
* macOS and Windows. Entries are released on click or close.
*/
const liveNotifications = new Set<Electron.Notification>();

export const make = ElectronNotifications.of({
show: (input) =>
Effect.sync(() => {
if (!Electron.Notification.isSupported()) {
return;
}

const notification = new Electron.Notification({
title: input.title,
body: input.body,
silent: input.silent,
});
liveNotifications.add(notification);

notification.on("click", () => {

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/ElectronNotifications.ts:52

Notifications that fail to deliver are never removed from liveNotifications, so the module-level Set grows without bound and each entry's onActivate closure is retained forever. Electron emits a failed event when native delivery fails and does not guarantee a close event (common for unsigned local macOS builds), so entries accumulate for every failed notification. Consider adding a failed handler that removes the notification from liveNotifications, and also remove it if notification.show() throws.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/electron/ElectronNotifications.ts around line 52:

Notifications that fail to deliver are never removed from `liveNotifications`, so the module-level `Set` grows without bound and each entry's `onActivate` closure is retained forever. Electron emits a `failed` event when native delivery fails and does not guarantee a `close` event (common for unsigned local macOS builds), so entries accumulate for every failed notification. Consider adding a `failed` handler that removes the notification from `liveNotifications`, and also remove it if `notification.show()` throws.

liveNotifications.delete(notification);
input.onActivate();
});
notification.on("close", () => {
liveNotifications.delete(notification);
});

notification.show();
}).pipe(
Effect.catchCause((cause) =>
logNotificationWarning("Failed to show a native notification.", { cause }),
),
),
});

export const layer = Layer.succeed(ElectronNotifications, 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 @@ -7,6 +7,7 @@ import {
getConnectionCatalog,
setConnectionCatalog,
} from "./methods/connectionCatalog.ts";
import { showNotification } from "./methods/notifications.ts";
import {
getAdvertisedEndpoints,
getServerExposureState,
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
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const SHOW_NOTIFICATION_CHANNEL = "desktop:show-notification";
export const NOTIFICATION_ACTIVATED_CHANNEL = "desktop:notification-activated";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/src/ipc/methods/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { assert, describe, it } from "@effect/vitest";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type Electron from "electron";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as ElectronNotifications from "../../electron/ElectronNotifications.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import { NOTIFICATION_ACTIVATED_CHANNEL } from "../channels.ts";
import { showNotification } from "./notifications.ts";

const THREAD_REF = {
environmentId: "env-1" as EnvironmentId,
threadId: "thread-1" as ThreadId,
};

const PAYLOAD = {
kind: "task-completed",
title: "Fix flaky auth test",
body: "Agent finished · t3code",
silent: false,
threadRef: THREAD_REF,
} as const;

describe("showNotification", () => {
it.effect("passes the renderer's copy straight through to the OS", () => {
const shown: Array<{ title: string; body: string; silent: boolean }> = [];

return Effect.gen(function* () {
yield* showNotification.handler(PAYLOAD);

assert.deepEqual(shown, [
{ title: "Fix flaky auth test", body: "Agent finished · t3code", silent: false },
]);
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(ElectronNotifications.ElectronNotifications)({
show: (input) =>
Effect.sync(() => {
shown.push({ title: input.title, body: input.body, silent: input.silent });
}),
}),
Layer.mock(ElectronWindow.ElectronWindow)({
currentMainOrFirst: Effect.succeed(Option.none()),
reveal: () => Effect.void,
sendAll: () => Effect.void,
}),
),
),
);
});

it.effect("reveals the window and reports the thread when clicked", () => {
const window = {} as Electron.BrowserWindow;
const revealed: Array<Electron.BrowserWindow> = [];
const sent: Array<readonly [string, ReadonlyArray<unknown>]> = [];
let activate: (() => void) | null = null;

return Effect.gen(function* () {
yield* showNotification.handler(PAYLOAD);

assert.isNotNull(activate);
activate?.();
// The activation runs as its own promise, so let the microtask queue drain.
yield* Effect.promise(() => Promise.resolve());

assert.deepEqual(revealed, [window]);
assert.deepEqual(sent, [[NOTIFICATION_ACTIVATED_CHANNEL, [{ threadRef: THREAD_REF }]]]);
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(ElectronNotifications.ElectronNotifications)({
show: (input) =>
Effect.sync(() => {
activate = input.onActivate;
}),
}),
Layer.mock(ElectronWindow.ElectronWindow)({
currentMainOrFirst: Effect.succeed(Option.some(window)),
reveal: (target) =>
Effect.sync(() => {
revealed.push(target);
}),
sendAll: (channel, ...args) =>
Effect.sync(() => {
sent.push([channel, args]);
}),
}),
),
),
);
});
});
44 changes: 44 additions & 0 deletions apps/desktop/src/ipc/methods/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { DesktopNotificationInputSchema } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as ElectronNotifications from "../../electron/ElectronNotifications.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";

export const showNotification = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SHOW_NOTIFICATION_CHANNEL,
payload: DesktopNotificationInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.notifications.show")(function* (input) {
const notifications = yield* ElectronNotifications.ElectronNotifications;
const electronWindow = yield* ElectronWindow.ElectronWindow;
// The click arrives from Electron outside any fiber, so capture the
// context here and run the activation as its own promise.
const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
const runPromise = Effect.runPromiseWith(context);

yield* notifications.show({
title: input.title,
body: input.body,
silent: input.silent,
onActivate: () => {
void runPromise(
Effect.gen(function* () {
// `currentMainOrFirst`, not `focusedMainOrFirst`: a notification is
// only clicked when the app is unfocused, so nothing is focused yet.
const window = yield* electronWindow.currentMainOrFirst;
if (Option.isSome(window)) {
yield* electronWindow.reveal(window.value);
}
yield* electronWindow.sendAll(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, {
threadRef: input.threadRef,
});
}),
);
},
});
}),
});
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts";
import * as ElectronApp from "./electron/ElectronApp.ts";
import * as ElectronDialog from "./electron/ElectronDialog.ts";
import * as ElectronMenu from "./electron/ElectronMenu.ts";
import * as ElectronNotifications from "./electron/ElectronNotifications.ts";
import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts";
import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts";
Expand Down Expand Up @@ -117,6 +118,7 @@ const electronLayer = Layer.mergeAll(
ElectronApp.layer,
ElectronDialog.layer,
ElectronMenu.layer,
ElectronNotifications.layer,
ElectronPowerMonitor.layer,
ElectronProtocol.layer,
ElectronSafeStorage.layer,
Expand Down
Loading
Loading