Skip to content

Commit f908526

Browse files
authored
fix: note race condition (OpenWhispr#1277)
1 parent 7c06935 commit f908526

7 files changed

Lines changed: 139 additions & 10 deletions

File tree

main.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ let ipcHandlers = null;
323323
let cliBridge = null;
324324
let globeKeyAlertShown = false;
325325
let authBridgeServer = null;
326+
let pendingNoteCloudId = null;
327+
let pendingNoteRetryTimer = null;
328+
let pendingNoteRetryCount = 0;
326329
const WHISPER_WAKE_REWARM_DELAY_MS = 3000;
327330
let wakeRewarmTimer = null;
328331

@@ -517,6 +520,11 @@ app.on("open-url", (event, url) => {
517520
return;
518521
}
519522

523+
if (isNoteDeepLink(url)) {
524+
void handleNoteDeepLink(url);
525+
return;
526+
}
527+
520528
if (isInvitationDeepLink(url)) {
521529
handleInvitationDeepLink(url);
522530
return;
@@ -535,6 +543,79 @@ function isInvitationDeepLink(url) {
535543
return url.slice(`${OAUTH_PROTOCOL}://`.length).startsWith("invitations/");
536544
}
537545

546+
function isNoteDeepLink(url) {
547+
return url.slice(`${OAUTH_PROTOCOL}://`.length).startsWith("notes/");
548+
}
549+
550+
function parseNoteCloudId(deepLinkUrl) {
551+
try {
552+
const match = deepLinkUrl.match(/notes\/([^/?#]+)/);
553+
const cloudId = match?.[1] ? decodeURIComponent(match[1]) : "";
554+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)
555+
? cloudId
556+
: null;
557+
} catch {
558+
return null;
559+
}
560+
}
561+
562+
function clearPendingNoteDeepLink() {
563+
clearTimeout(pendingNoteRetryTimer);
564+
pendingNoteRetryTimer = null;
565+
pendingNoteCloudId = null;
566+
pendingNoteRetryCount = 0;
567+
}
568+
569+
async function flushPendingNoteDeepLink() {
570+
if (!pendingNoteCloudId || !windowManager || !databaseManager) return;
571+
572+
try {
573+
// Surface the panel on the first attempt only; retries just poll the
574+
// database so they can't repeatedly steal focus.
575+
if (pendingNoteRetryCount === 0) {
576+
await windowManager.createControlPanelWindow();
577+
}
578+
579+
const note = databaseManager.getNoteByCloudId(pendingNoteCloudId);
580+
if (!note) {
581+
// Cloud sync may still be hydrating during a cold launch. Retry briefly so
582+
// the handoff can resolve a note pulled after the protocol event arrived.
583+
pendingNoteRetryCount += 1;
584+
if (pendingNoteRetryCount <= 10) {
585+
clearTimeout(pendingNoteRetryTimer);
586+
pendingNoteRetryTimer = setTimeout(() => {
587+
void flushPendingNoteDeepLink();
588+
}, 1000);
589+
} else {
590+
console.warn("Note deep link could not resolve a local note", {
591+
cloudId: pendingNoteCloudId,
592+
});
593+
clearPendingNoteDeepLink();
594+
}
595+
return;
596+
}
597+
598+
const payload = { noteId: note.id, folderId: note.folder_id ?? null };
599+
clearPendingNoteDeepLink();
600+
await windowManager.queueNoteNavigation(payload);
601+
} catch (error) {
602+
console.error("Note deep link failed:", error);
603+
clearPendingNoteDeepLink();
604+
}
605+
}
606+
607+
async function handleNoteDeepLink(deepLinkUrl) {
608+
const cloudId = parseNoteCloudId(deepLinkUrl);
609+
if (!cloudId) {
610+
console.warn("Invalid note deep link");
611+
return;
612+
}
613+
614+
clearPendingNoteDeepLink();
615+
pendingNoteCloudId = cloudId;
616+
await flushPendingNoteDeepLink();
617+
}
618+
538619
function handleInvitationDeepLink(deepLinkUrl) {
539620
try {
540621
const match = deepLinkUrl.match(/invitations\/([^/?#]+)/);
@@ -863,6 +944,13 @@ async function startApp() {
863944
await windowManager.createControlPanelWindow();
864945
}
865946

947+
const initialProtocolUrl = process.argv.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
948+
if (initialProtocolUrl && isNoteDeepLink(initialProtocolUrl)) {
949+
await handleNoteDeepLink(initialProtocolUrl);
950+
} else {
951+
await flushPendingNoteDeepLink();
952+
}
953+
866954
// Create agent window (hidden) and set up agent hotkey
867955
await windowManager.createAgentWindow();
868956

@@ -1557,6 +1645,8 @@ if (gotSingleInstanceLock) {
15571645
if (url) {
15581646
if (url.includes("upgrade-success")) {
15591647
handleUpgradeDeepLink();
1648+
} else if (isNoteDeepLink(url)) {
1649+
await handleNoteDeepLink(url);
15601650
} else if (isInvitationDeepLink(url)) {
15611651
handleInvitationDeepLink(url);
15621652
} else {
@@ -1677,6 +1767,7 @@ function performSyncTeardown() {
16771767
clearTimeout(wakeRewarmTimer);
16781768
wakeRewarmTimer = null;
16791769
}
1770+
clearPendingNoteDeepLink();
16801771
if (authBridgeServer) {
16811772
authBridgeServer.close();
16821773
authBridgeServer = null;

preload.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -981,9 +981,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
981981
"meeting-note-navigation-pending",
982982
(callback) => () => callback()
983983
),
984-
onNavigateToNote: registerListener(
985-
"navigate-to-note",
986-
(callback) => (_event, data) => callback(data)
984+
getPendingNoteNavigation: () => ipcRenderer.invoke("get-pending-note-navigation"),
985+
onNoteNavigationPending: registerListener(
986+
"note-navigation-pending",
987+
(callback) => () => callback()
987988
),
988989

989990
onUpdateNotificationData: registerListener(

src/components/ControlPanel.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,14 +372,18 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
372372
}, []);
373373

374374
useEffect(() => {
375-
const cleanup = window.electronAPI?.onNavigateToNote?.((data) => {
375+
const drain = async () => {
376+
const data = await window.electronAPI?.getPendingNoteNavigation?.();
377+
if (!data) return;
376378
if (data.folderId) {
377379
setActiveFolderId(data.folderId);
378380
initializeNotes(null, 50, data.folderId);
379381
}
380382
setActiveNoteId(data.noteId);
381383
setActiveView("personal-notes");
382-
});
384+
};
385+
drain();
386+
const cleanup = window.electronAPI?.onNoteNavigationPending?.(drain);
383387
return () => cleanup?.();
384388
}, []);
385389

src/helpers/database.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,21 @@ class DatabaseManager {
14641464
}
14651465
}
14661466

1467+
getNoteByCloudId(cloudId) {
1468+
try {
1469+
if (!this.db) {
1470+
throw new Error("Database not initialized");
1471+
}
1472+
const stmt = this.db.prepare(
1473+
"SELECT * FROM notes WHERE cloud_id = ? AND deleted_at IS NULL LIMIT 1"
1474+
);
1475+
return stmt.get(cloudId) || null;
1476+
} catch (error) {
1477+
debugLogger.error("Error getting note by cloud_id", { error: error.message }, "notes");
1478+
throw error;
1479+
}
1480+
}
1481+
14671482
getNotes(noteType = null, limit = 100, folderId = null) {
14681483
try {
14691484
if (!this.db) {

src/helpers/ipcHandlers.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6947,8 +6947,7 @@ class IPCHandlers {
69476947
ipcMain.handle("agent-open-note", async (_event, noteId) => {
69486948
try {
69496949
const note = this.databaseManager.getNote(noteId);
6950-
await this.windowManager.createControlPanelWindow();
6951-
this.windowManager.sendToControlPanel("navigate-to-note", {
6950+
await this.windowManager.queueNoteNavigation({
69526951
noteId,
69536952
folderId: note?.folder_id ?? null,
69546953
});
@@ -8806,6 +8805,10 @@ class IPCHandlers {
88068805
return this.windowManager?.consumePendingMeetingNoteNavigation() ?? null;
88078806
});
88088807

8808+
ipcMain.handle("get-pending-note-navigation", async () => {
8809+
return this.windowManager?.consumePendingNoteNavigation() ?? null;
8810+
});
8811+
88098812
ipcMain.handle("meeting-notification-ready", async () => {
88108813
this.windowManager?.showNotificationWindow();
88118814
});

src/helpers/windowManager.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class WindowManager {
4848
this._panelStartPosition = "bottom-right";
4949
this._isDictatingToggle = false;
5050
this._pendingMeetingNoteNavigation = null;
51+
this._pendingNoteNavigation = null;
5152

5253
app.on("before-quit", () => {
5354
this.isQuitting = true;
@@ -1377,6 +1378,18 @@ class WindowManager {
13771378
return payload;
13781379
}
13791380

1381+
async queueNoteNavigation(payload) {
1382+
this._pendingNoteNavigation = payload;
1383+
await this.createControlPanelWindow();
1384+
this.sendToControlPanel("note-navigation-pending");
1385+
}
1386+
1387+
consumePendingNoteNavigation() {
1388+
const payload = this._pendingNoteNavigation;
1389+
this._pendingNoteNavigation = null;
1390+
return payload;
1391+
}
1392+
13801393
snapControlPanelToMeetingMode() {
13811394
const win = this.controlPanelWindow;
13821395
if (!win || win.isDestroyed()) return;

src/types/electron.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,9 +2001,11 @@ declare global {
20012001
trigger?: "hotkey" | "manual" | "calendar-join";
20022002
} | null>;
20032003
onMeetingNoteNavigationPending?: (callback: () => void) => () => void;
2004-
onNavigateToNote?: (
2005-
callback: (data: { noteId: number; folderId: number | null }) => void
2006-
) => () => void;
2004+
getPendingNoteNavigation?: () => Promise<{
2005+
noteId: number;
2006+
folderId: number | null;
2007+
} | null>;
2008+
onNoteNavigationPending?: (callback: () => void) => () => void;
20072009
onUpdateNotificationData?: (
20082010
callback: (data: { version: string; releaseDate?: string }) => void
20092011
) => () => void;

0 commit comments

Comments
 (0)