diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index eea21481..7d701400 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -32,10 +32,11 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService } from "@x/core/dist/di/container.js"; +import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; +import { ElectronNotificationService } from "./notification/electron-notification-service.js"; const execAsync = promisify(exec); @@ -231,6 +232,7 @@ app.whenReady().then(async () => { await initConfigs(); registerBrowserControlService(new ElectronBrowserControlService()); + registerNotificationService(new ElectronNotificationService()); setupIpcHandlers(); setupBrowserEventForwarding(); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts new file mode 100644 index 00000000..04f28449 --- /dev/null +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -0,0 +1,37 @@ +import { BrowserWindow, Notification, shell } from "electron"; +import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; + +const HTTP_URL = /^https?:\/\//i; + +export class ElectronNotificationService implements INotificationService { + isSupported(): boolean { + return Notification.isSupported(); + } + + notify({ title = "Rowboat", message, link }: NotifyInput): void { + const notification = new Notification({ + title, + body: message, + }); + + notification.on("click", () => { + if (link && HTTP_URL.test(link)) { + shell.openExternal(link).catch((err) => { + console.error("[notification] failed to open link:", err); + }); + return; + } + this.focusMainWindow(); + }); + + notification.show(); + } + + private focusMainWindow(): void { + const [win] = BrowserWindow.getAllWindows(); + if (!win) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + } +} diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 52083277..f390ddae 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -27,6 +27,7 @@ import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js"; import type { IBrowserControlService } from "../browser-control/service.js"; +import type { INotificationService } from "../notification/service.js"; // Parser libraries are loaded dynamically inside parseFile.execute() // to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. // Import paths are computed so esbuild cannot statically resolve them. @@ -1514,4 +1515,37 @@ export const BuiltinTools: z.infer = { } }, }, + + 'notify-user': { + description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.", + inputSchema: z.object({ + title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."), + message: z.string().min(1).describe("Body text of the notification."), + link: z.string().url().refine((v) => /^https?:\/\//i.test(v), { + message: "link must be an http:// or https:// URL", + }).optional().describe("Optional http(s) URL opened when the user clicks the notification."), + }), + isAvailable: async () => { + try { + return container.resolve('notificationService').isSupported(); + } catch { + return false; + } + }, + execute: async ({ title, message, link }: { title?: string; message: string; link?: string }) => { + try { + const service = container.resolve('notificationService'); + if (!service.isSupported()) { + return { success: false, error: 'Notifications are not supported on this system' }; + } + service.notify({ title, message, link }); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, }; diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts new file mode 100644 index 00000000..5e596853 --- /dev/null +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -0,0 +1,10 @@ +export interface NotifyInput { + title?: string; + message: string; + link?: string; +} + +export interface INotificationService { + isSupported(): boolean; + notify(input: NotifyInput): void; +} diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 93ba9ebd..9382de8b 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -16,6 +16,7 @@ import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo. import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; import type { IBrowserControlService } from "../application/browser-control/service.js"; +import type { INotificationService } from "../application/notification/service.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -49,3 +50,9 @@ export function registerBrowserControlService(service: IBrowserControlService): browserControlService: asValue(service), }); } + +export function registerNotificationService(service: INotificationService): void { + container.register({ + notificationService: asValue(service), + }); +}