feat(x): resident app — launch at login + menu bar tray (Granola parity phase 1)

- Register as an OS login item on first packaged run (default on); the OS
  registry stays the source of truth afterward, with a toggle in
  Settings > Appearance > System
- Menu bar tray with template icon (derived from the app glyph, embedded
  as base64): Open Rowboat / Start-Stop meeting notes / Quit
- App keeps running with no windows while the tray exists; launches at
  login start hidden (menu bar only), Dock/tray reveals the window
- Tray menu reflects live recording state via meeting:setRecordingState;
  tray-initiated toggles survive window-loading races through a pending
  command drained on renderer mount (same pattern as deep links)
- GRANOLA_PARITY.md: research doc — how Granola works, current-state
  audit, gap analysis, phased parity plan
This commit is contained in:
Gagan 2026-07-13 21:02:20 +05:30
parent 4182794b76
commit 9b0776596d
8 changed files with 513 additions and 8 deletions

153
apps/x/GRANOLA_PARITY.md Normal file
View file

@ -0,0 +1,153 @@
# Granola Parity — Research & Gap Analysis
Goal: make Rowboat's meeting feature work exactly like Granola (granola.ai). We copy Granola's behavior — no new invention. This doc is the source of truth: how Granola works, what Rowboat has today (with file:line pointers), the gaps, and the parity plan.
Research basis: Granola's official docs (docs.granola.ai), granola.ai blog/security/jobs pages, third-party reviews and reverse-engineering writeups, plus a full audit of this codebase. Claims that are inferred (not directly documented by Granola) are marked **[inference]**.
---
## Part 1 — How Granola works
Granola is an Electron app (confirmed by their own job postings: "Granola is an Electron app with deep OS integrations", React/TypeScript UI + native OS helpers). No bot ever joins the call — it captures audio locally on the device, so nothing is visible to other meeting participants.
### 1.1 Always-running app (launch at login)
- The product model is an **always-running, menu-bar-resident app**. Granola must be running for detection/notifications to work ("You must have Granola open... for transcription to run" — their troubleshooting docs).
- A literal "open at login" toggle is **not publicly documented**. **[inference]** A clone should register as a login item (Electron `app.setLoginItemSettings`), default on, with a setting to turn it off — the entire detection value prop collapses if the app isn't running.
- The app keeps running when the window closes (menu bar presence remains).
### 1.2 Menu bar icon
- Granola "sits in your Mac's menu bar". Documented affordance: **click the menu bar icon to start recording / open the app**.
- Recording status is NOT primarily shown in the menu bar. The documented indicators are:
- Inside the note: "green dancing bars" at the bottom while capturing.
- When another app has focus: a **floating always-on-top "live meeting" pill** on the right side of the screen.
- Exact dropdown contents (upcoming meetings list, icon state change while recording) are not documented. The "Coming up" list lives on the home screen, not the tray.
### 1.3 Meeting detection — two signals, never auto-record
Granola detects meetings via **calendar events + microphone-in-use detection**, and it **never records without a user action** ("Granola only starts transcribing when you open a note for that meeting").
**Signal A — Calendar:**
- Google / Microsoft calendar sync. Pre-creates an auto-titled note per event with 2+ attendees; filters declined events, OOO, Focus Time.
- **In-app popup (Granola-drawn, not macOS Notification Center) 1 minute before** any scheduled call with 2+ attendees. One click on it **opens the meeting URL AND starts transcription** at once.
- **Armed auto-start:** if you have the upcoming meeting's note open before it starts, recording starts automatically at the scheduled time.
**Signal B — Mic-in-use (ad-hoc calls, incl. browser meetings):**
- "Granola notices when your microphone is in use and offers to start taking notes."
- It's app-aware: notification title is "**Huddle detected**" (Slack), "**Call detected**" (FaceTime/WhatsApp), "**Meeting detected**" (anything else, including browsers). Buttons: "Take Notes". Ad-hoc popups have a dashed left border; calendar ones a solid colored bar.
- If the ad-hoc call starts within **15 minutes** of a scheduled event, the popup adopts that event's name (merged).
- **[inference]** Mechanism: poll CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` on input devices for "mic in use", plus enumerate running processes against a known meeting-app list to get the app-specific title. There's no public macOS API for "process X is using the mic", so it's a heuristic combo. Browser meetings degrade to the generic "Meeting detected" title; calendar linkage supplies identity.
- Notifications are configurable in Settings (off entirely, or per-application).
- Related preference: "**Reposition Granola for meetings**" — window auto-repositions/resizes alongside the call when a meeting starts (toggleable, default on).
### 1.4 Audio capture
- Capture = **default microphone + system audio output**, at the OS level. Works with any app or browser (Chrome/Safari/Firefox/Edge) with no extension.
- Cannot isolate per-app audio — it's the combined system stream; uses the OS default sound devices.
- The two streams stay separate through transcription → transcript UI shows **grey bubbles (left) = system audio ("Them"), green bubbles (right) = your mic ("Me")**. No true live diarization on desktop — just Me/Them.
- **macOS permissions: exactly two** — Microphone, and Screen & System Audio Recording (macOS bundles system-audio capture under screen recording). No Accessibility permission.
- **[inference]** System audio mechanism: ScreenCaptureKit audio loopback (macOS 13+ baseline, they require 13+ / recommend 14.2+), possibly CoreAudio process taps on 14.4+. No virtual audio driver (setup has no driver install step).
- **Audio is never stored** — streamed to the ASR provider in real time and discarded.
### 1.5 Transcription
- Cloud, real-time streaming ASR. Subprocessors named on their security page: **Deepgram and AssemblyAI** for ASR; **OpenAI and Anthropic** for note enhancement.
- Live transcript accrues during the call, hidden behind a waveform-icon toggle in the note.
### 1.6 Meeting end & post-meeting flow
- **Auto-stop conditions** (documented): (a) call-end heuristic — transcript inactivity + whether the call software is still in use + scheduled end time for calendar-linked meetings; (b) **15 minutes of silence**; (c) computer sleeps; (d) manual stop. Note: "on macOS, meeting auto-end detection requires admin rights" — without it, manual stop only.
- **On stop, enhancement runs automatically** — no click needed. Enhanced notes are ready in seconds (~30s max reported).
- A "**notes ready**" notification fires when enhancement completes — that's the re-entry point back into the app if you switched away. The note itself was already open (recording ran inside it), so the enhanced version replaces the raw view in place. There's no documented "force-focus the window" behavior; the notification does the redirecting.
- Extras: auto-drafted follow-up emails (toggleable), pre-meeting briefs.
### 1.7 Notes UI
**During the meeting:** a plain notepad — the user types rough bullets; transcription runs invisibly. Green dancing bars at the bottom = capturing. Waveform button (next to the per-note "Ask anything" chat bar) opens the live transcript side-by-side. Mid-meeting you can ask the chat to catch you up.
**After the meeting (enhanced notes):**
- Enhancement merges exactly three inputs: **transcript + your raw notes + calendar metadata**, through a template.
- Signature affordance: **your words render black, AI-generated text grey**.
- Per-line provenance: magnifying-glass icon on a line reveals where it came from in the transcript.
- Templates: built-ins (1:1, standup, sales discovery) + custom; re-enhance with a different template (✨), regenerate (🔁), edit raw notes and re-enhance.
- Chat: per-note "Ask anything" bar, global chat (⌘J), cross-meeting chat over folders; edit-by-asking.
- Home screen: "**Coming up**" strip (next ~5 meetings) + reverse-chronological past notes list; folders; recurring meetings grouped by recurring-event ID + title; shareable links.
---
## Part 2 — What Rowboat has today (audit of main)
Two separate feature families exist in `apps/x`; only the second is Granola-adjacent:
- **"Calls" (video mode)** — live voice/video chat *with the Rowboat AI* (`VIDEO_MODE.md`). Not meeting capture.
- **"Meetings"** — real meeting capture → transcript → AI notes. Working pipeline, but **calendar-triggered and manual-click only**.
### What EXISTS (and is solid)
| Area | Status | Where |
|---|---|---|
| Mic + system-audio capture, 2-channel | ✅ | `apps/renderer/src/hooks/useMeetingTranscription.ts:282-299` — mic `getUserMedia` (ch 0) + `getDisplayMedia({audio})` loopback (ch 1), merged to 16 kHz PCM (`:424-485`); loopback auto-approved in `apps/main/src/main.ts:216-226` |
| Realtime ASR | ✅ | Deepgram realtime WS, `nova-3`, multichannel + diarize (`useMeetingTranscription.ts:9-21`); proxy or raw key (`:253-280`) |
| Speaker labels | ✅ | ch 0 → "You", ch 1 → diarized `Speaker N` (`:335-347`) |
| Transcript storage | ✅ | Markdown + frontmatter + fenced transcript block, `knowledge/Meetings/rowboat/<date>/<name>.md` (`:93-136`, `:487-510`) |
| AI meeting notes on stop | ✅ | `packages/core/src/knowledge/summarize_meeting.ts` — LLM summary, attendee-name resolution from calendar; orchestrated in `App.tsx:5601-5661`; notes prepended above transcript |
| Auto-stop heuristics | ✅ (partial) | silence RMS backstop, calendar-end gating, system-track ended/muted — `useMeetingTranscription.ts:378-549` |
| Calendar sync | ✅ | Google OAuth via `googleapis`, `packages/core/src/knowledge/sync_calendar.ts`, per-event JSON in `~/.rowboat/calendar_sync/` |
| Pre-meeting notification | ✅ (system toast) | `packages/core/src/knowledge/notify_calendar_meetings.ts` — polls every 30s, notifies ~1 min before, deep-links `rowboat://action?type=join-and-take-meeting-notes` |
| Meetings screen | ✅ | `apps/renderer/src/components/meetings-view.tsx` — "Coming up" (+Join / Take-notes buttons, inline prep) + past-notes table |
| Transcript rendering | ✅ | `apps/renderer/src/extensions/transcript-block.tsx` (TipTap, colored speakers, collapsible) |
| Meeting prep briefs | ✅ | `meeting_prep_scheduler.ts`, `meeting_prep_brief.ts` (Granola has this too) |
| Permissions flows | ✅ | mic `ipc.ts:2090-2106`; screen recording check/open-settings `ipc.ts:2003-2018`; Info.plist strings `forge.config.cjs:201-204`; entitlements OK |
### What is MISSING (the entire "ambient" layer)
| # | Gap | Detail |
|---|---|---|
| 1 | **Launch at login** | Zero `setLoginItemSettings` / auto-launch code anywhere. |
| 2 | **Menu bar (tray) icon** | Zero `Tray` usage. No background presence: on macOS closing the window leaves only the Dock; no way to start recording without the full window. |
| 3 | **Meeting detection** | No mic-in-use detection, no process detection (Zoom/Teams/Slack/FaceTime), no browser awareness. Only calendar (time-based). Ad-hoc calls are invisible. |
| 4 | **Auto/one-click start UX** | Capture starts only via explicit button click or clicking the calendar toast. No "Meeting detected — Take notes?" popup, no armed auto-start when a note is open at meeting start time. |
| 5 | **Headless capture** | Capture depends on the renderer window holding a live `getDisplayMedia` stream. No native audio helper → can't capture while the app is "in the background" the way Granola does. |
| 6 | **Meeting-end redirect** | On stop, the note refreshes in place, but there's **no "notes ready" notification** and no bring-the-user-back moment if they're in another app. |
| 7 | **Notes UI polish (Granola signatures)** | No black-vs-grey authorship distinction (we replace, they merge raw notes + transcript), no during-meeting notepad-first flow (we show the transcript note), no per-line provenance, no templates/re-enhance, no floating "recording" pill. |
### Honest assessment
Rowboat has built the *second half* of Granola well — what happens once you're recording, and after the meeting ends. It has essentially none of the *first half* — noticing a meeting is happening and quietly being there without the user opening the app. That first half is exactly items 15 above, and it's where all the work is.
---
## Part 3 — Parity plan (copy Granola, no new stuff)
Ordered so each phase is shippable and testable on its own.
### Phase 1 — Resident app: login item + tray
- `app.setLoginItemSettings({ openAtLogin: true })`, default on, toggle in Settings. When launched at login: no window, tray only **[inference — Granola undocumented, but implied]**.
- Electron `Tray` with template icon; menu: "Start recording", "Open Rowboat", recording status line, Quit. Keep app alive on window close (already macOS default; add tray so it's reachable).
- Acceptance: reboot → icon in menu bar, no window; click tray → start an ad-hoc meeting note.
### Phase 2 — Meeting detection + "Take notes?" popup
- Native signal (small Swift helper or node addon, polled from main): mic-in-use via CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` + running-process match against a known list (zoom.us, Teams, Slack, FaceTime, browsers…).
- Granola-style app-drawn popup (small always-on-top BrowserWindow, like the existing video popout): "Huddle detected / Call detected / Meeting detected — [Take Notes]". Merge with a calendar event if within 15 min. Never auto-record.
- Upgrade the existing 1-min-before calendar toast to the same popup style; one click = open meeting URL + start capture (deep-link plumbing already exists in `deeplink.ts`).
- Armed auto-start: meeting note open before start time → auto-start at start time.
- Per-app notification settings.
- Acceptance: start a Meet call in Chrome with no calendar event → popup appears; click → recording.
### Phase 3 — Meeting end → notes ready redirect
- Keep existing auto-stop heuristics; add the missing "call software no longer active" signal from the Phase-2 process watcher; keep 15-min silence backstop (ours is stricter — align to Granola's 15 min).
- On summary completion: fire a **"Your meeting notes are ready" notification**; clicking focuses the app on the note (deep link exists). This is the "redirect when we cut the call" the feature needs.
- Acceptance: leave the call → recording stops on its own → notification within ~30s → click lands on finished notes.
### Phase 4 — Notes UI parity
- During meeting: notepad-first note (user types; transcript behind a waveform toggle); green capture indicator in-note; floating "live meeting" pill when app unfocused (reuse the video-popout window machinery).
- After meeting: enhancement merges **raw notes + transcript + calendar event** (today we only use the transcript); render user text black / AI text grey; ✨ re-enhance + 🔁 regenerate; keep transcript below as today.
- Home: Meetings view already ≈ Granola's home (Coming up + past list) — minor polish only.
### Phase 5 (only if needed for true parity) — Headless capture
- Native mic + ScreenCaptureKit system-audio capture in main/helper process so recording doesn't require the renderer window. Biggest lift; Phases 14 deliver the Granola experience with the window opening on record start, which is acceptable Granola-like behavior (their note opens on start too).
### Key implementation notes
- Permissions stay exactly two (mic + screen-recording) — we already handle both.
- Deepgram nova-3 multichannel already matches Granola's Me/Them model — no ASR change needed.
- Reuse: popup ← video popout window pattern; deep links ← `deeplink.ts`; detection loop ← same main-process init pattern as `notify_calendar_meetings.ts`.

View file

@ -66,6 +66,8 @@ import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import { saveAppSettings } from '@x/core/dist/config/app_settings.js';
import { consumePendingToggleMeetingNotes, setTrayRecordingState } from './tray.js';
import * as composioHandler from './composio-handler.js';
import * as appsIndexer from '@x/core/dist/apps/indexer.js';
import * as appsServer from '@x/core/dist/apps/server.js';
@ -839,6 +841,31 @@ export function setupIpcHandlers() {
'app:consumePendingDeepLink': async () => {
return { url: consumePendingDeepLink() };
},
'app:consumePendingTrayCommand': async () => {
return { toggleMeetingNotes: consumePendingToggleMeetingNotes() };
},
'app:getLoginItemSettings': async () => {
// Dev builds never register a login item (it would point at the dev
// Electron binary), so report off.
if (!app.isPackaged) return { openAtLogin: false };
return { openAtLogin: app.getLoginItemSettings().openAtLogin };
},
'app:setLoginItemSettings': async (_event, args) => {
if (app.isPackaged) {
app.setLoginItemSettings({
openAtLogin: args.openAtLogin,
...(process.platform === 'win32' ? { args: ['--hidden'] } : {}),
});
// The user has expressed an explicit choice — never re-apply the
// first-run default over it.
saveAppSettings({ loginItemRegistered: true });
}
return { success: true as const };
},
'meeting:setRecordingState': async (_event, args) => {
setTrayRecordingState(args.recording);
return { success: true as const };
},
'analytics:bootstrap': async () => {
return {
installationId: getInstallationId(),

View file

@ -1,5 +1,6 @@
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron";
import path from "node:path";
import os from "node:os";
import {
setupIpcHandlers,
startRunsWatcher, startSessionsWatcher, startTurnEventsWatcher, markSessionsIndexReady,
@ -65,6 +66,8 @@ import {
} from "./deeplink.js";
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
import { startModelsDevRefresh } from "@x/core/dist/models/models-dev.js";
import { loadAppSettings, saveAppSettings } from "@x/core/dist/config/app_settings.js";
import { createAppTray, hasTray, markPendingToggleMeetingNotes } from "./tray.js";
// Captured as early as possible so it reflects actual process start. Used to
// gate grace-eligible notifications (e.g. the burst of background-task
@ -258,7 +261,46 @@ function setupZoomShortcuts(win: BrowserWindow) {
});
}
function createWindow() {
// Resident-app plumbing (Granola-style): the main window may exist hidden
// (launched at login) or not at all (closed on macOS) while the app keeps
// running from the tray. showApp() is the single "bring the app up" path
// used by the tray, the Dock, and pending tray commands.
let mainWindow: BrowserWindow | null = null;
function showApp(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.maximize();
mainWindow.show();
mainWindow.focus();
return;
}
createWindow();
}
/**
* Was this process launched by the OS at login (rather than by the user)?
* Used to start with the window hidden so login launches are invisible.
*
* - Windows: our login item registers with an explicit --hidden arg.
* - macOS: wasOpenedAtLogin when available. On macOS 13+ (SMAppService)
* Electron doesn't reliably populate it (electron#37244), so fall back to
* a heuristic: a packaged launch while registered as a login item within
* two minutes of boot is treated as a login launch.
*/
function wasLaunchedAtLogin(): boolean {
if (process.argv.includes("--hidden")) return true;
if (process.platform !== "darwin" || !app.isPackaged) return false;
try {
const settings = app.getLoginItemSettings();
if (settings.wasOpenedAtLogin) return true;
return settings.openAtLogin && os.uptime() < 120;
} catch {
return false;
}
}
function createWindow(options: { startHidden?: boolean } = {}) {
const win = new BrowserWindow({
width: 1280,
height: 800,
@ -284,11 +326,18 @@ function createWindow() {
configureSessionPermissions(session.defaultSession);
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
mainWindow = win;
setMainWindowForDeepLinks(win);
win.on("closed", () => setMainWindowForDeepLinks(null));
win.on("closed", () => {
if (mainWindow === win) mainWindow = null;
setMainWindowForDeepLinks(null);
});
// Show window when content is ready to prevent blank screen
// Show window when content is ready to prevent blank screen.
// Launched-at-login starts stay hidden: the app is reachable from the
// tray/Dock, and showApp() maximizes on first reveal.
win.once("ready-to-show", () => {
if (options.startHidden) return;
win.maximize();
win.show();
});
@ -419,7 +468,41 @@ app.whenReady().then(async () => {
console.error('[Apps] Failed to start:', error);
});
createWindow();
// Resident app (Granola-style): register as an OS login item once, on the
// first packaged run. After that the OS registry is the source of truth —
// the Settings toggle writes it directly, and disabling the login item in
// System Settings sticks because we never re-register on boot.
if (app.isPackaged && !loadAppSettings().loginItemRegistered) {
try {
app.setLoginItemSettings({
openAtLogin: true,
...(process.platform === "win32" ? { args: ["--hidden"] } : {}),
});
saveAppSettings({ loginItemRegistered: true });
} catch (error) {
console.error("[LoginItem] Failed to register login item:", error);
}
}
createWindow({ startHidden: wasLaunchedAtLogin() });
// Menu bar icon: open the app / start-stop meeting notes without the
// window. If the renderer isn't ready to receive the toggle (window closed
// or still loading), park it as a pending command the renderer drains on
// mount — same pull pattern as pending deep links.
createAppTray({
openApp: showApp,
toggleMeetingNotes: () => {
const hadWindow = mainWindow !== null && !mainWindow.isDestroyed();
showApp();
const win = mainWindow;
if (!hadWindow || !win || win.webContents.isLoading()) {
markPendingToggleMeetingNotes();
return;
}
win.webContents.send("app:toggleMeetingNotes", null);
},
});
// Start workspace watcher as a main-process service
// Watcher runs independently and catches ALL filesystem changes:
@ -547,14 +630,16 @@ app.whenReady().then(async () => {
initChromeSync();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
// Reveal the hidden/closed main window (login launches start hidden).
showApp();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
// Resident app: with a tray present, keep running with no windows so
// meeting detection/notifications stay alive (Granola-style). Without a
// tray (creation failed), fall back to the platform-default quit.
if (process.platform !== "darwin" && !hasTray()) {
app.quit();
}
});

View file

@ -0,0 +1,107 @@
import { app, Menu, Tray, nativeImage } from "electron";
/**
* Menu bar / system tray presence (Granola-style resident app).
*
* The icon is the app glyph pre-rendered as a macOS "template" image
* (pure black + alpha, derived from icons/icon.png: alpha = pixel
* luminance, so the white sail becomes an opaque black shape and the
* black rounded square becomes transparent). Embedded as base64 so the
* tray never depends on asset paths that differ between dev and
* packaged layouts. Template rendering makes macOS tint it correctly
* in light/dark menu bars and while highlighted.
*/
const TRAY_ICON_16 =
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABD0lEQVR4nKSSz+pBQRTH585vfgtrllYiSkpJHkAegLJQlvIE9soD2MvSE3gCG57ARiwoe2Kl/Lm+J2d0XJd7y7c+dc898z1zzswYFV6OwAU3+zNIf9LglwwyWqKgBKogBpZAa+XfKukKIqABxmABJqAPcnat8Zi1aLUNOiAl8hfeYMaxazw7kzkNRqAoOpFjHcCc45sWZpqzxtVpxqPIac6TpmDP/5UtQMkkz1YGCZAHA89YDndnv1+u8R+c1buGoMVFtiADTjYpb+HMsSNy9N3llinusfnpC/OQSBuwAwWO7Xko88VkDzarHo+owvHLpkEFSHXQBCtR9G3RJ9HBxsHazxymgFzn+iWM+sFMugMAAP//whjxNQAAAAZJREFUAwCzrTaLm3OP9AAAAABJRU5ErkJggg==";
const TRAY_ICON_32 =
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACfUlEQVR4nMSXO2gUURSGz8yuYuGjsBHFBza+UGwULUxjoY0iWPnA0lZQURDsFC1E7Owt7JQ0SZGQLk2SNoQEQhICCXlUCXk/ZvOf3HM2Z2/uZDM7m90fPnbm3jn3P/e5M0VqsorUZBWocYqogQnEhj1VzwTUsOTBOgJOg1WwYYMiyqdITDe9RK6CW+A2uAYugOPgBhiXuO3kal2E1pg5BO6BJ+A+uEK7OzcJpuVaR6amBArG+Bx4BZ6LqZUOdSI+I+SmoEJZEoilMTa+CN6Cl+CEMUrkuUgS5d91+e33OpApAQ06Cj6CN+CY1G14pqryPMt1b6jhagloI2z+EPwCl4xxgXabqnQXFCW+R8oTaxDvw5z1DbSL+Zpp2D4bpcTz7zAYMomVVaxizkP+FzwydYfJ9Uh3QskYWXGZrolOiamY/7QE1Pwk6AaXwTzoA7OghdyhQtJY7PVWzcnU/fPKK8xC5mdBB1gGv6UHY/IMr/rH4D25gyUtCS3n4efDaJ1Seuvf8xC/BhPgv1dnTz1+7qskogvSrhsu4wPqM/hCgeEPJUC0s99VBbkveWXa2A/wzktC45fILdxJL7kKM1+JV7cZCNRFyIYfwICXqA7/HzGPA22Ue5KmElWXjtYceCrXuiVXwDOp27OBPNJpaAVT5HaVTsVPcv98qb0nqs/7ABvw4XQTXJf7UfBCEkyqBeeV9u6UXHObvIsW9xOcNwFd2WfAHWnvO+iinUV5oAlo/ANyx3Yb+EQpez6keryS8QgMkjvp7oIFStnzIeX5LtAtyK9h3OOWrOZ5E9C/5PPk3gdnspoT5Z8Ce2xnNtegeqgmc1YjP82CavrHadMT2AIAAP//JKuLxQAAAAZJREFUAwAJ75pCeqjxVQAAAABJRU5ErkJggg==";
interface TrayActions {
openApp: () => void;
toggleMeetingNotes: () => void;
}
let tray: Tray | null = null;
let actions: TrayActions | null = null;
let recording = false;
// Tray commands issued while the renderer wasn't ready to receive them
// (window closed or still loading). Drained by the renderer on mount via
// app:consumePendingTrayCommand — same pull pattern as pending deep links.
let pendingToggleMeetingNotes = false;
export function markPendingToggleMeetingNotes(): void {
pendingToggleMeetingNotes = true;
}
export function consumePendingToggleMeetingNotes(): boolean {
const value = pendingToggleMeetingNotes;
pendingToggleMeetingNotes = false;
return value;
}
function buildTrayIcon() {
const icon = nativeImage.createEmpty();
icon.addRepresentation({
scaleFactor: 1,
buffer: Buffer.from(TRAY_ICON_16, "base64"),
});
icon.addRepresentation({
scaleFactor: 2,
buffer: Buffer.from(TRAY_ICON_32, "base64"),
});
icon.setTemplateImage(true);
return icon;
}
export function createAppTray(trayActions: TrayActions): void {
if (tray) return;
actions = trayActions;
try {
tray = new Tray(buildTrayIcon());
} catch (error) {
// Tray support can be missing (some Linux environments). The app just
// behaves as before: no resident presence.
console.error("[Tray] Failed to create tray:", error);
return;
}
rebuildMenu();
// macOS opens the context menu on any click. On Windows/Linux a plain
// left-click should open the app; the menu stays on right-click.
if (process.platform !== "darwin") {
tray.on("click", () => actions?.openApp());
}
}
export function hasTray(): boolean {
return tray !== null;
}
export function setTrayRecordingState(isRecording: boolean): void {
if (recording === isRecording) return;
recording = isRecording;
rebuildMenu();
}
function rebuildMenu(): void {
if (!tray) return;
const menu = Menu.buildFromTemplate([
{ label: "Open Rowboat", click: () => actions?.openApp() },
recording
? {
label: "Stop recording and generate notes",
click: () => actions?.toggleMeetingNotes(),
}
: {
label: "Start meeting notes",
click: () => actions?.toggleMeetingNotes(),
},
{ type: "separator" },
{ label: "Quit Rowboat", click: () => app.quit() },
]);
tray.setContextMenu(menu);
tray.setToolTip(recording ? "Rowboat — recording meeting" : "Rowboat");
}

View file

@ -1101,6 +1101,14 @@ function App() {
handleToggleMeetingRef.current?.()
})
// Keep the tray menu in sync with meeting capture ("Start meeting notes"
// vs "Stop recording & generate notes").
useEffect(() => {
void window.ipc
.invoke('meeting:setRecordingState', { recording: meetingTranscription.state === 'recording' })
.catch(() => { /* tray may be unavailable */ })
}, [meetingTranscription.state])
// Check if voice is available on mount and when OAuth state changes
const refreshVoiceAvailability = useCallback(() => {
Promise.all([
@ -4678,6 +4686,18 @@ function App() {
return () => observer.disconnect()
}, [])
// Tray menu "Start/Stop meeting notes": same toggle as the Meetings header
// button. Also drains a toggle parked while the window was closed/loading
// (mirrors the pending deep-link pull above).
useEffect(() => {
void window.ipc.invoke('app:consumePendingTrayCommand', null).then(({ toggleMeetingNotes }) => {
if (toggleMeetingNotes) handleToggleMeetingRef.current?.()
})
return window.ipc.on('app:toggleMeetingNotes', () => {
handleToggleMeetingRef.current?.()
})
}, [])
// Triggered by main when the user clicks a calendar-meeting notification.
// Reuses the same flow as the in-app "Join meeting & take notes" button.
// When `openMeeting` is true, also opens the meeting URL in the system browser.

View file

@ -226,11 +226,48 @@ function ThemeOption({
)
}
function LaunchAtLoginSetting() {
const [openAtLogin, setOpenAtLogin] = useState(false)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
window.ipc.invoke("app:getLoginItemSettings", null)
.then(({ openAtLogin }) => setOpenAtLogin(openAtLogin))
.catch(() => { /* dev builds report off */ })
.finally(() => setLoaded(true))
}, [])
const handleToggle = async (next: boolean) => {
setOpenAtLogin(next)
try {
await window.ipc.invoke("app:setLoginItemSettings", { openAtLogin: next })
} catch {
setOpenAtLogin(!next)
}
}
return (
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">Start Rowboat when you log in</div>
<div className="text-xs text-muted-foreground mt-0.5">
Keeps Rowboat in your menu bar so meeting notes and notifications work without opening the app
</div>
</div>
<Switch checked={openAtLogin} onCheckedChange={handleToggle} disabled={!loaded} />
</div>
)
}
function AppearanceSettings() {
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium mb-3">System</h4>
<LaunchAtLoginSetting />
</div>
<div>
<h4 className="text-sm font-medium mb-3">Theme</h4>
<p className="text-xs text-muted-foreground mb-4">

View file

@ -0,0 +1,36 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from './config.js';
const APP_SETTINGS_PATH = path.join(WorkDir, 'config', 'app_settings.json');
export interface AppSettings {
/**
* Set once the app has registered itself as an OS login item (first run
* of a packaged build, or the user touching the Settings toggle). After
* this, the OS login-item registry is the source of truth the app never
* re-registers on boot, so disabling it in System Settings sticks.
*/
loginItemRegistered?: boolean;
}
export function loadAppSettings(): AppSettings {
try {
if (fs.existsSync(APP_SETTINGS_PATH)) {
const parsed = JSON.parse(fs.readFileSync(APP_SETTINGS_PATH, 'utf-8'));
if (parsed && typeof parsed === 'object') return parsed as AppSettings;
}
} catch (error) {
console.error('[AppSettings] Error loading app settings:', error);
}
return {};
}
export function saveAppSettings(patch: Partial<AppSettings>): void {
const merged = { ...loadAppSettings(), ...patch };
const dir = path.dirname(APP_SETTINGS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(APP_SETTINGS_PATH, JSON.stringify(merged, null, 2));
}

View file

@ -743,6 +743,46 @@ const ipcSchemas = {
url: z.string().nullable(),
}),
},
// Tray commands issued before the renderer was ready (mirrors the pending
// deep-link pull above): the renderer drains this once on mount.
'app:consumePendingTrayCommand': {
req: z.null(),
res: z.object({
toggleMeetingNotes: z.boolean(),
}),
},
// Main → renderer: tray menu "Start/Stop meeting notes" — runs the same
// toggle flow as the Meetings header button.
'app:toggleMeetingNotes': {
req: z.null(),
res: z.null(),
},
// Launch-at-login (resident app). The OS login-item registry is the source
// of truth; these read/write it directly rather than a config file.
'app:getLoginItemSettings': {
req: z.null(),
res: z.object({
openAtLogin: z.boolean(),
}),
},
'app:setLoginItemSettings': {
req: z.object({
openAtLogin: z.boolean(),
}),
res: z.object({
success: z.literal(true),
}),
},
// Renderer → main: meeting capture state, so the tray menu/tooltip can
// reflect an active recording.
'meeting:setRecordingState': {
req: z.object({
recording: z.boolean(),
}),
res: z.object({
success: z.literal(true),
}),
},
'granola:getConfig': {
req: z.null(),
res: z.object({