diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index 96717409..6dd828cb 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -1,7 +1,11 @@ import { BrowserWindow } from "electron"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; export const DEEP_LINK_SCHEME = "rowboat"; const URL_PREFIX = `${DEEP_LINK_SCHEME}://`; +const ACTION_HOST = "action"; let pendingUrl: string | null = null; let mainWindowRef: BrowserWindow | null = null; @@ -23,6 +27,18 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null return null; } +/** + * Dispatch any rowboat:// URL — chooses navigation vs action automatically. + * Use this from notification click handlers and other URL entry points. + */ +export function dispatchUrl(url: string): void { + if (parseAction(url)) { + void dispatchAction(url); + } else { + dispatchDeepLink(url); + } +} + export function dispatchDeepLink(url: string): void { if (!url.startsWith(URL_PREFIX)) return; @@ -30,13 +46,72 @@ export function dispatchDeepLink(url: string): void { const win = mainWindowRef; if (!win || win.isDestroyed()) return; - - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); + focusWindow(win); if (win.webContents.isLoading()) return; win.webContents.send("app:openUrl", { url }); pendingUrl = null; } + +interface TakeMeetingNotesAction { + type: "take-meeting-notes"; + eventId: string; +} + +type ParsedAction = TakeMeetingNotesAction; + +function parseAction(url: string): ParsedAction | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, ""); + if (host !== ACTION_HOST) return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const type = params.get("type"); + if (type === "take-meeting-notes") { + const eventId = params.get("eventId"); + return eventId ? { type, eventId } : null; + } + return null; +} + +async function dispatchAction(url: string): Promise { + const parsed = parseAction(url); + if (!parsed) return; + + if (parsed.type === "take-meeting-notes") { + await handleTakeMeetingNotes(parsed.eventId); + } +} + +async function handleTakeMeetingNotes(eventId: string): Promise { + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); + let event: unknown; + try { + const raw = await fs.readFile(filePath, "utf-8"); + event = JSON.parse(raw); + } catch (err) { + console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); + return; + } + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + win.webContents.send("app:takeMeetingNotes", { event }); + }); + return; + } + + win.webContents.send("app:takeMeetingNotes", { event }); +} + +function focusWindow(win: BrowserWindow): void { + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 284cfdfa..50f39a90 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -23,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; +import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; @@ -337,6 +338,9 @@ app.whenReady().then(async () => { // start agent notes learning service initAgentNotes(); + // start calendar meeting notification service (fires 1-minute warnings) + initCalendarNotifications(); + // start chrome extension sync server initChromeSync(); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts index 75a33c21..e9c8145b 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -1,6 +1,6 @@ import { BrowserWindow, Notification, shell } from "electron"; import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; -import { dispatchDeepLink } from "../deeplink.js"; +import { dispatchUrl } from "../deeplink.js"; const HTTP_URL = /^https?:\/\//i; const ROWBOAT_URL = /^rowboat:\/\//i; @@ -15,18 +15,23 @@ export class ElectronNotificationService implements INotificationService { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel }: NotifyInput): void { const notification = new Notification({ title, body: message, + // Action button is only meaningful when there's a link to drive it. + // macOS shows the first action inline (Banner) or behind chevron (Alert). + actions: link && actionLabel?.trim() + ? [{ type: "button", text: actionLabel.trim() }] + : [], }); this.active.add(notification); const release = () => { this.active.delete(notification); }; - notification.on("click", () => { + const handleClick = () => { if (link && ROWBOAT_URL.test(link)) { - dispatchDeepLink(link); + dispatchUrl(link); } else if (link && HTTP_URL.test(link)) { shell.openExternal(link).catch((err) => { console.error("[notification] failed to open link:", err); @@ -35,7 +40,13 @@ export class ElectronNotificationService implements INotificationService { this.focusMainWindow(); } release(); - }); + }; + + // Both events route through the same handler. Body click on macOS is + // unreliable when actions are defined, but we register both so either + // one (whichever fires) drives the same behavior. + notification.on("click", handleClick); + notification.on("action", handleClick); notification.on("close", release); notification.on("failed", release); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e42b3342..8587111a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3105,6 +3105,35 @@ function App() { return window.ipc.on('app:openUrl', ({ url }) => handle(url)) }, []) + // Triggered by main when the user clicks a calendar-meeting notification. + // Reuses the same flow as the in-app "Join meeting & take notes" button. + useEffect(() => { + return window.ipc.on('app:takeMeetingNotes', ({ event }) => { + const e = event as { + summary?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } + location?: string + htmlLink?: string + hangoutLink?: string + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + } + if (!e || typeof e !== 'object') return + const conferenceLink = e.hangoutLink + || e.conferenceData?.entryPoints?.find(p => p.entryPointType === 'video')?.uri + window.__pendingCalendarEvent = { + summary: e.summary, + start: e.start, + end: e.end, + location: e.location, + htmlLink: e.htmlLink, + conferenceLink, + source: 'calendar-sync', + } + window.dispatchEvent(new Event('calendar-block:join-meeting')) + }) + }, []) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) }, []) 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 7bf6bde1..67209b6c 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1524,6 +1524,7 @@ export const BuiltinTools: z.infer = { link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), { message: "link must be an http(s):// or rowboat:// URL", }).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."), + actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body. Note: macOS may render the button inline (Banner) or behind a chevron (Alert) depending on the user's notification style for Rowboat."), }), isAvailable: async () => { try { @@ -1532,13 +1533,13 @@ export const BuiltinTools: z.infer = { return false; } }, - execute: async ({ title, message, link }: { title?: string; message: string; link?: string }) => { + execute: async ({ title, message, link, actionLabel }: { title?: string; message: string; link?: string; actionLabel?: 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 }); + service.notify({ title, message, link, actionLabel }); return { success: true }; } catch (error) { return { diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts index 5e596853..a90c8e13 100644 --- a/apps/x/packages/core/src/application/notification/service.ts +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -2,6 +2,7 @@ export interface NotifyInput { title?: string; message: string; link?: string; + actionLabel?: string; } export interface INotificationService { diff --git a/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts new file mode 100644 index 00000000..60e84ded --- /dev/null +++ b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts @@ -0,0 +1,171 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import { WorkDir } from "../config/config.js"; +import container from "../di/container.js"; +import type { INotificationService } from "../application/notification/service.js"; + +const TICK_INTERVAL_MS = 60_000; +// Notify when an event is between 30s in the past (started just now) and +// 90s in the future (about to start). The window is wider than 60s so we +// don't miss an event if the tick lands slightly off the start time. +const NOTIFY_LEAD_MS = 90_000; +const NOTIFY_GRACE_MS = 30_000; +// Drop state entries older than 24h so the file doesn't grow forever. +const STATE_TTL_MS = 24 * 60 * 60 * 1000; + +const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync"); +const STATE_FILE = path.join(WorkDir, "calendar_notifications_state.json"); + +interface NotificationState { + notifiedEventIds: Record; +} + +interface CalendarEvent { + id?: string; + summary?: string; + status?: string; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string }; + attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>; + hangoutLink?: string; + conferenceData?: { + entryPoints?: Array<{ entryPointType?: string; uri?: string }>; + }; +} + +async function loadState(): Promise { + try { + const raw = await fs.readFile(STATE_FILE, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.notifiedEventIds) { + return parsed as NotificationState; + } + } catch { + // No state file yet, or corrupt — start fresh. + } + return { notifiedEventIds: {} }; +} + +async function saveState(state: NotificationState): Promise { + await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf-8"); +} + +function gcState(state: NotificationState): NotificationState { + const cutoff = Date.now() - STATE_TTL_MS; + const fresh: NotificationState["notifiedEventIds"] = {}; + for (const [id, entry] of Object.entries(state.notifiedEventIds)) { + const ts = Date.parse(entry.notifiedAt); + if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry; + } + return { notifiedEventIds: fresh }; +} + +function isAllDay(event: CalendarEvent): boolean { + // Google Calendar all-day events have `date` (YYYY-MM-DD) on start, not `dateTime`. + return !event.start?.dateTime; +} + +function isDeclinedBySelf(event: CalendarEvent): boolean { + if (!event.attendees) return false; + const self = event.attendees.find((a) => a.self); + return self?.responseStatus === "declined"; +} + +async function tick(state: NotificationState): Promise<{ state: NotificationState; dirty: boolean }> { + let entries: Dirent[]; + try { + entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true }); + } catch { + return { state, dirty: false }; + } + + let service: INotificationService; + try { + service = container.resolve("notificationService"); + } catch { + // Notification service not registered yet (very early startup) — skip this tick. + return { state, dirty: false }; + } + if (!service.isSupported()) return { state, dirty: false }; + + const now = Date.now(); + let dirty = false; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + if (entry.name === "sync_state.json" || entry.name.startsWith("sync_state")) continue; + + const eventId = entry.name.replace(/\.json$/, ""); + if (state.notifiedEventIds[eventId]) continue; + + const filePath = path.join(CALENDAR_SYNC_DIR, entry.name); + let event: CalendarEvent; + try { + event = JSON.parse(await fs.readFile(filePath, "utf-8")); + } catch { + continue; + } + + if (event.status === "cancelled") continue; + if (isAllDay(event)) continue; + if (isDeclinedBySelf(event)) continue; + + const startStr = event.start?.dateTime; + if (!startStr) continue; + const startMs = Date.parse(startStr); + if (!Number.isFinite(startMs)) continue; + + const msUntilStart = startMs - now; + if (msUntilStart > NOTIFY_LEAD_MS) continue; + if (msUntilStart < -NOTIFY_GRACE_MS) continue; + + const summary = event.summary?.trim() || "Untitled meeting"; + const link = `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(eventId)}`; + + try { + service.notify({ + title: "Upcoming meeting", + message: `${summary} starts in 1 minute. Click to take notes.`, + link, + actionLabel: "Take Notes", + }); + console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`); + } catch (err) { + console.error(`[CalendarNotify] notify failed for ${eventId}:`, err); + continue; + } + + state.notifiedEventIds[eventId] = { + notifiedAt: new Date().toISOString(), + startTime: startStr, + }; + dirty = true; + } + + return { state, dirty }; +} + +export async function init(): Promise { + console.log("[CalendarNotify] starting calendar notification service"); + console.log(`[CalendarNotify] tick every ${TICK_INTERVAL_MS / 1000}s`); + + let state = gcState(await loadState()); + + while (true) { + try { + const result = await tick(state); + state = result.state; + if (result.dirty) { + try { + await saveState(state); + } catch (err) { + console.error("[CalendarNotify] failed to save state:", err); + } + } + } catch (err) { + console.error("[CalendarNotify] tick failed:", err); + } + await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS)); + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 769de949..86f53316 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -298,6 +298,13 @@ const ipcSchemas = { }), res: z.null(), }, + 'app:takeMeetingNotes': { + req: z.object({ + // Pass the raw calendar event JSON through; renderer adapts to its existing flow. + event: z.unknown(), + }), + res: z.null(), + }, 'app:consumePendingDeepLink': { req: z.null(), res: z.object({