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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Install and first run](./docs/user/install.md)
- [Permission modes](./docs/user/permission-modes.md)
- [Keyboard shortcuts](./docs/user/keybindings.md)
- [Desktop notifications](./docs/user/desktop-notifications.md)
- [Customize a project icon](./docs/user/project-settings.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Keeping app and server in sync](./docs/user/updating.md)
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import * as Effect from "effect/Effect";

import * as DesktopIpc from "./DesktopIpc.ts";
import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
import {
dismissAllDesktopNotifications,
dismissDesktopNotification,
showDesktopNotification,
showDesktopNotificationTest,
} from "./methods/notifications.ts";
import {
clearConnectionCatalog,
getConnectionCatalog,
Expand Down Expand Up @@ -56,6 +62,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"

yield* ipc.handle(getClientSettings);
yield* ipc.handle(setClientSettings);
yield* ipc.handle(showDesktopNotification);
yield* ipc.handle(dismissDesktopNotification);
yield* ipc.handle(dismissAllDesktopNotifications);
yield* ipc.handle(showDesktopNotificationTest);
yield* ipc.handle(getConnectionCatalog);
yield* ipc.handle(setConnectionCatalog);
yield* ipc.handle(clearConnectionCatalog);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
"desktop:get-local-environment-bearer-token";
export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
export const DESKTOP_NOTIFICATION_SHOW_CHANNEL = "desktop:notification-show";
export const DESKTOP_NOTIFICATION_DISMISS_CHANNEL = "desktop:notification-dismiss";
export const DESKTOP_NOTIFICATION_DISMISS_ALL_CHANNEL = "desktop:notification-dismiss-all";
export const DESKTOP_NOTIFICATION_SHOW_TEST_CHANNEL = "desktop:notification-show-test";
export const DESKTOP_NOTIFICATION_ACTIVATED_CHANNEL = "desktop:notification-activated";
export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog";
export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog";
export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog";
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/ipc/methods/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
DesktopNotificationShowInputSchema,
DesktopNotificationShowResultSchema,
DesktopNotificationTargetSchema,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as DesktopNotifications from "../../notifications/DesktopNotifications.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";

export const showDesktopNotification = DesktopIpc.makeIpcMethod({
channel: IpcChannels.DESKTOP_NOTIFICATION_SHOW_CHANNEL,
payload: DesktopNotificationShowInputSchema,
result: DesktopNotificationShowResultSchema,
handler: Effect.fn("desktop.ipc.notifications.show")(function* (input) {
const notifications = yield* DesktopNotifications.DesktopNotifications;
return yield* notifications.show(input);
}),
});

export const dismissDesktopNotification = DesktopIpc.makeIpcMethod({
channel: IpcChannels.DESKTOP_NOTIFICATION_DISMISS_CHANNEL,
payload: DesktopNotificationTargetSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.notifications.dismiss")(function* (target) {
const notifications = yield* DesktopNotifications.DesktopNotifications;
yield* notifications.dismiss(target);
}),
});

export const dismissAllDesktopNotifications = DesktopIpc.makeIpcMethod({
channel: IpcChannels.DESKTOP_NOTIFICATION_DISMISS_ALL_CHANNEL,
payload: Schema.Void,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.notifications.dismissAll")(function* () {
const notifications = yield* DesktopNotifications.DesktopNotifications;
yield* notifications.dismissAll;
}),
});

export const showDesktopNotificationTest = DesktopIpc.makeIpcMethod({
channel: IpcChannels.DESKTOP_NOTIFICATION_SHOW_TEST_CHANNEL,
payload: Schema.Struct({ silent: Schema.Boolean }),
result: DesktopNotificationShowResultSchema,
handler: Effect.fn("desktop.ipc.notifications.showTest")(function* (input) {
const notifications = yield* DesktopNotifications.DesktopNotifications;
return yield* notifications.showTest(input);
}),
});
6 changes: 6 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
import * as DesktopState from "./app/DesktopState.ts";
import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
import * as DesktopNotifications from "./notifications/DesktopNotifications.ts";
import * as BrowserSession from "./preview/BrowserSession.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
Expand Down Expand Up @@ -156,6 +157,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe(
Layer.provideMerge(desktopPreviewLayer),
);

const desktopNotificationLayer = DesktopNotifications.layer.pipe(
Layer.provideMerge(desktopWindowLayer),
);

// Pool layer instantiates the backend factory once for the Windows
// primary instance and exposes it via pool.primary. Consumers go through
// the pool now; the legacy DesktopBackendManager service is gone. The
Expand Down Expand Up @@ -187,6 +192,7 @@ const desktopApplicationLayer = Layer.mergeAll(
DesktopShellEnvironment.layer,
desktopSshLayer,
).pipe(
Layer.provideMerge(desktopNotificationLayer),
Layer.provideMerge(DesktopUpdates.layer),
Layer.provideMerge(desktopWslBackendLayer),
Layer.provideMerge(desktopLocalEnvironmentAuthLayer),
Expand Down
171 changes: 171 additions & 0 deletions apps/desktop/src/notifications/DesktopNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import type * as Electron from "electron";

import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopNotifications from "./DesktopNotifications.ts";

class FakeNativeNotification implements DesktopNotifications.NativeNotification {
readonly listeners = new Map<"click" | "close", () => void>();
shown = false;
closed = false;

show(): void {
this.shown = true;
}

close(): void {
this.closed = true;
this.listeners.get("close")?.();
}

once(event: "click" | "close", listener: () => void): void {
this.listeners.set(event, listener);
}

on(_event: "failed", _listener: (event: unknown, error: string) => void): void {}
}

function makeWindowLayer(onReveal: Effect.Effect<void>) {
const sent: Array<{ readonly channel: string; readonly payload: unknown }> = [];
const window = {
isDestroyed: () => false,
webContents: {
isLoadingMainFrame: () => false,
once: () => undefined,
send: (channel: string, payload: unknown) => sent.push({ channel, payload }),
},
} as unknown as Electron.BrowserWindow;

return {
sent,
layer: Layer.succeed(
DesktopWindow.DesktopWindow,
DesktopWindow.DesktopWindow.of({
createMain: Effect.succeed(window),
ensureMain: Effect.succeed(window),
revealOrCreateMain: onReveal.pipe(Effect.as(window)),
activate: Effect.void,
createMainIfBackendReady: Effect.void,
showConnectingSplash: Effect.void,
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchMenuAction: () => Effect.void,
zoomMain: () => Effect.void,
syncAppearance: Effect.void,
}),
),
};
}

const input = {
environmentId: EnvironmentId.make("env-1"),
threadId: ThreadId.make("thread-1"),
event: "approval" as const,
projectTitle: "t3code",
threadTitle: "Fix failing CI",
showContext: true,
silent: false,
};

describe("DesktopNotifications", () => {
it.effect("passes shared copy to the native adapter and replaces a thread notification", () =>
Effect.gen(function* () {
const created: Array<{
readonly options: DesktopNotifications.NativeNotificationOptions;
readonly notification: FakeNativeNotification;
}> = [];
const platform: DesktopNotifications.DesktopNotificationPlatformService["Service"] = {
isSupported: () => true,
isAppFocused: () => false,
create: (options) => {
const notification = new FakeNativeNotification();
created.push({ options, notification });
return notification;
},
};
const window = makeWindowLayer(Effect.void);

yield* Effect.gen(function* () {
const notifications = yield* DesktopNotifications.DesktopNotifications;
expect(yield* notifications.show(input)).toBe("shown");
expect(yield* notifications.show({ ...input, event: "failure" })).toBe("shown");
}).pipe(
Effect.provide(DesktopNotifications.layerTest(platform).pipe(Layer.provide(window.layer))),
Effect.scoped,
);

expect(created[0]?.options).toEqual({
title: "Approval needed",
body: "Fix failing CI · t3code",
silent: false,
timeoutType: "default",
});
expect(created[0]?.notification.closed).toBe(true);
expect(created[1]?.notification.shown).toBe(true);
}),
);

it.effect("suppresses agent notifications while the app is focused but still allows tests", () =>
Effect.gen(function* () {
const created: FakeNativeNotification[] = [];
const platform: DesktopNotifications.DesktopNotificationPlatformService["Service"] = {
isSupported: () => true,
isAppFocused: () => true,
create: () => {
const notification = new FakeNativeNotification();
created.push(notification);
return notification;
},
};
const window = makeWindowLayer(Effect.void);

yield* Effect.gen(function* () {
const notifications = yield* DesktopNotifications.DesktopNotifications;
expect(yield* notifications.show(input)).toBe("suppressed");
expect(created).toHaveLength(0);
expect(yield* notifications.showTest({ silent: true })).toBe("shown");
expect(created).toHaveLength(1);
}).pipe(
Effect.provide(DesktopNotifications.layerTest(platform).pipe(Layer.provide(window.layer))),
Effect.scoped,
);
}),
);

it.effect("reveals the app and forwards the target when a notification is clicked", () =>
Effect.gen(function* () {
const revealed = yield* Deferred.make<void>();
let notification: FakeNativeNotification | null = null;
const platform: DesktopNotifications.DesktopNotificationPlatformService["Service"] = {
isSupported: () => true,
isAppFocused: () => false,
create: () => {
notification = new FakeNativeNotification();
return notification;
},
};
const window = makeWindowLayer(Deferred.succeed(revealed, undefined));

yield* Effect.gen(function* () {
const notifications = yield* DesktopNotifications.DesktopNotifications;
yield* notifications.show(input);
notification?.listeners.get("click")?.();
yield* Deferred.await(revealed);
}).pipe(
Effect.provide(DesktopNotifications.layerTest(platform).pipe(Layer.provide(window.layer))),
Effect.scoped,
);

expect(window.sent[0]?.payload).toEqual({
environmentId: input.environmentId,
threadId: input.threadId,
});
}),
);
});
Loading