mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
- 2px progress line drains toward auto-dismiss (now 30s); hovering the popup freezes both the line and the dismissal. The renderer owns the countdown; main keeps only a 3-min crash-safety fallback - soft synthesized arrival chime (WebAudio — no asset, no spawn) - close button in the top-left corner, hover-revealed - call-end grace 3s → 1s with a 1s detector poll: notes flow starts within ~2s of hang-up
160 lines
6.3 KiB
TypeScript
160 lines
6.3 KiB
TypeScript
import { app, BrowserWindow, screen } from "electron";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { DetectedMeeting } from "@x/core/dist/meetings/detector.js";
|
|
|
|
/**
|
|
* The "Meeting detected — Take Notes?" popup: a small frameless panel in the
|
|
* top-left corner of the display the user is on. A macOS panel (NSPanel) at
|
|
* screen-saver level with fullscreen-auxiliary behavior, so it floats over
|
|
* whatever the user is looking at — including a fullscreen Meet/Zoom — and
|
|
* appears without stealing focus. Auto-dismisses after a while and never
|
|
* records anything by itself: clicking "Take Notes" hands off to the main
|
|
* window's existing take-meeting-notes flow.
|
|
*/
|
|
|
|
// The window IS the card: exact card dimensions, card background, native
|
|
// rounded corners + shadow. No transparency — macOS panels don't honor
|
|
// `transparent: true` reliably and paint a grey backing slab instead.
|
|
const POPUP_WIDTH = 376;
|
|
const POPUP_HEIGHT = 48;
|
|
// The popup renderer owns the real 45s countdown (it pauses on hover and
|
|
// draws the progress line); this is only a crash-safety net so a wedged
|
|
// renderer can't leave the popup on screen forever.
|
|
const FALLBACK_DISMISS_MS = 180_000;
|
|
|
|
// Display names, Granola-style ("Chrome", not "Google Chrome").
|
|
const SHORT_APP_NAMES: Record<string, string> = {
|
|
"Google Chrome": "Chrome",
|
|
"Microsoft Edge": "Edge",
|
|
"Brave Browser": "Brave",
|
|
"Microsoft Teams": "Teams",
|
|
};
|
|
|
|
export interface MeetingPopupPayload {
|
|
title: string;
|
|
message: string;
|
|
hasCalendarEvent: boolean;
|
|
}
|
|
|
|
let popupWin: BrowserWindow | null = null;
|
|
let currentPayload: MeetingPopupPayload | null = null;
|
|
let currentMeeting: DetectedMeeting | null = null;
|
|
let dismissTimer: NodeJS.Timeout | null = null;
|
|
let onTakeNotes: ((meeting: DetectedMeeting) => void) | null = null;
|
|
|
|
/** Main registers the take-notes handoff once at startup. */
|
|
export function initMeetingPopup(handlers: { onTakeNotes: (meeting: DetectedMeeting) => void }): void {
|
|
onTakeNotes = handlers.onTakeNotes;
|
|
}
|
|
|
|
export function getMeetingPopupPayload(): MeetingPopupPayload | null {
|
|
return currentPayload;
|
|
}
|
|
|
|
export function handleMeetingPopupAction(action: "take-notes" | "dismiss"): void {
|
|
const meeting = currentMeeting;
|
|
closeMeetingPopup();
|
|
if (action === "take-notes" && meeting) {
|
|
onTakeNotes?.(meeting);
|
|
}
|
|
}
|
|
|
|
export function closeMeetingPopup(): void {
|
|
if (dismissTimer) {
|
|
clearTimeout(dismissTimer);
|
|
dismissTimer = null;
|
|
}
|
|
currentPayload = null;
|
|
currentMeeting = null;
|
|
if (popupWin && !popupWin.isDestroyed()) popupWin.destroy();
|
|
popupWin = null;
|
|
}
|
|
|
|
export function showMeetingPopup(meeting: DetectedMeeting): void {
|
|
const eventSummary =
|
|
typeof meeting.calendarEvent?.summary === "string"
|
|
? (meeting.calendarEvent.summary as string).trim()
|
|
: "";
|
|
const appLabel = SHORT_APP_NAMES[meeting.appName] ?? meeting.appName;
|
|
const payload: MeetingPopupPayload = {
|
|
// Lean two-liner: "Meeting detected" / "Chrome" — or the event name
|
|
// over the platform when a calendar event is happening now.
|
|
title: eventSummary ? eventSummary : meeting.title,
|
|
message: appLabel,
|
|
hasCalendarEvent: Boolean(meeting.calendarEvent),
|
|
};
|
|
|
|
// Replace any popup that's still up (stale detection loses to fresh).
|
|
closeMeetingPopup();
|
|
currentPayload = payload;
|
|
currentMeeting = meeting;
|
|
|
|
// Top-left of the display the user is actually on (cursor display), not
|
|
// necessarily the primary one.
|
|
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
|
|
const workArea = display.workArea;
|
|
const popupDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const preloadPath = app.isPackaged
|
|
? path.join(popupDir, "../preload/dist/preload.js")
|
|
: path.join(popupDir, "../../../preload/dist/preload.js");
|
|
|
|
const win = new BrowserWindow({
|
|
width: POPUP_WIDTH,
|
|
height: POPUP_HEIGHT,
|
|
x: workArea.x + 24,
|
|
// Sit a bit clear of the menu bar rather than hugging it.
|
|
y: workArea.y + 44,
|
|
// NSPanel (macOS): non-activating, and — unlike a regular window with
|
|
// visibleOnFullScreen — can float over fullscreen Spaces without
|
|
// turning Rowboat into an "agent" app that loses its Dock icon.
|
|
...(process.platform === "darwin" ? { type: "panel" as const } : {}),
|
|
frame: false,
|
|
resizable: false,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
show: false,
|
|
backgroundColor: "#1d1d1d",
|
|
hasShadow: true,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
preload: preloadPath,
|
|
},
|
|
});
|
|
// Screen-saver level + fullscreen-auxiliary: visible over fullscreen
|
|
// meeting apps on every workspace — the whole point of the popup is to be
|
|
// seen while the user is IN the meeting, wherever that is.
|
|
win.setAlwaysOnTop(true, "screen-saver");
|
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
// visibleOnFullScreen flips the app's activation policy to "accessory":
|
|
// the Dock icon disappears and the app can no longer take foreground
|
|
// focus — clicking "Take Notes" then hands focus to whatever is next in
|
|
// the window stack instead of Rowboat. Restore the policy immediately:
|
|
// the popup keeps its fullscreen-auxiliary collection behavior (it's a
|
|
// non-activating panel, same trick as Zoom's floating controls), while
|
|
// Rowboat stays a regular app.
|
|
if (process.platform === "darwin") {
|
|
void app.dock?.show();
|
|
}
|
|
win.webContents.once("did-finish-load", () => {
|
|
if (win.isDestroyed()) return;
|
|
win.webContents.send("meetingDetect:payload", payload);
|
|
// showInactive: the user is in a meeting app right now — appearing
|
|
// must not steal focus from it.
|
|
win.showInactive();
|
|
});
|
|
win.on("closed", () => {
|
|
if (popupWin === win) popupWin = null;
|
|
});
|
|
popupWin = win;
|
|
|
|
if (app.isPackaged) {
|
|
win.loadURL("app://-/index.html#meeting-detected");
|
|
} else {
|
|
win.loadURL("http://localhost:5173/#meeting-detected");
|
|
}
|
|
|
|
dismissTimer = setTimeout(() => closeMeetingPopup(), FALLBACK_DISMISS_MS);
|
|
}
|