diff --git a/js/desktop/src/App.tsx b/js/desktop/src/App.tsx index b31e7c4..5a8da62 100644 --- a/js/desktop/src/App.tsx +++ b/js/desktop/src/App.tsx @@ -52,6 +52,19 @@ const App = () => { ); }; +function DeepLinkNavigationListener() { + const navigate = useNavigate(); + + useEffect(() => { + window.electronDeepLink.getPending().then((path) => { + if (path) navigate(path); + }); + return window.electronDeepLink.onNavigate((path) => navigate(path)); + }, [navigate]); + + return null; +} + function AutoplayNavigationListener() { const navigate = useNavigate(); @@ -68,6 +81,7 @@ function AuthenticatedApp() { return ( + diff --git a/js/desktop/src/electron.d.ts b/js/desktop/src/electron.d.ts index aceb98c..c3972f1 100644 --- a/js/desktop/src/electron.d.ts +++ b/js/desktop/src/electron.d.ts @@ -44,6 +44,10 @@ declare global { stop: () => void; onInit: (callback: () => void) => () => void; }; + electronDeepLink: { + getPending: () => Promise; + onNavigate: (callback: (path: string) => void) => () => void; + }; electronLink: { openExternal: (url: string) => Promise; }; diff --git a/js/desktop/src/main.ts b/js/desktop/src/main.ts index 603e2aa..1ecb397 100644 --- a/js/desktop/src/main.ts +++ b/js/desktop/src/main.ts @@ -53,6 +53,8 @@ if (!app.isDefaultProtocolClient('llink')) { let mainWindow: BrowserWindow | null = null; let autoplayWindow: BrowserWindow | null = null; +let pendingDeepLink: string | null = null; +let rendererReady = false; let huddleWindow: BrowserWindow | null = null; let screenRecordWindow: BrowserWindow | null = null; @@ -423,9 +425,46 @@ ipcMain.on( }, ); +// Extracts the in-app route path from a llink:// URL. +// llink://networkId/streamId → /networkId/streamId +// llink:// or llink://open → / (root) +function deepLinkPath(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'llink:') return null; + const host = parsed.hostname; + if (!host || host === 'open') return '/'; + return `/${host}${parsed.pathname}`; + } catch { + return null; + } +} + +function tryNavigateToDeepLink(url: string) { + // Before the renderer has mounted (cold start), there's no onNavigate + // listener yet — stash the link so the renderer can pull it via getPending. + if (!rendererReady || !mainWindow) { + pendingDeepLink = url; + return; + } + + const path = deepLinkPath(url); + if (!path) return; + + focusMainWindow(); + mainWindow.webContents.send('deep-link:navigate', path); +} + app.on('ready', () => { createWindow(); createAutoplayWindow(); + + // On Windows/Linux, a cold-start llink:// click passes the URL as a process argument. + // macOS cold start is handled via open-url, which fires after ready. + const coldStartUrl = process.argv.find((arg) => arg.startsWith('llink://')); + if (coldStartUrl) { + tryNavigateToDeepLink(coldStartUrl); + } }); app.on('window-all-closed', () => { @@ -448,17 +487,27 @@ function focusMainWindow() { mainWindow.focus(); } -// macOS delivers llink:// URLs via this event, both when the app is already -// running and on cold start (after `ready`). We prevent the default to silence -// Electron's warning and focus the window — OS-level focus alone won't restore -// a hidden or minimized window. Cold start is handled by createWindow(). -app.on('open-url', (event) => { +// macOS: fired on cold start and when app is already running. +app.on('open-url', (event, url) => { event.preventDefault(); - focusMainWindow(); + tryNavigateToDeepLink(url); // stashes if the renderer isn't ready yet (cold start) or pushes live otherwise. }); // Windows/Linux: the OS launches a second process with the URL in argv; the -// single-instance lock diverts it here on the primary instance. -app.on('second-instance', () => { - focusMainWindow(); +// single-instance lock diverts it here, executed on the primary instance main process. +app.on('second-instance', (_event, argv) => { + const url = argv.find((arg) => arg.startsWith('llink://')); + if (url) { + tryNavigateToDeepLink(url); + } else { + focusMainWindow(); + } +}); + +// Renderer pulls any pending deep link on mount (cold-start case). +safeHandle('deep-link:get-pending', () => { + rendererReady = true; + const url = pendingDeepLink; + pendingDeepLink = null; + return url ? deepLinkPath(url) : null; }); diff --git a/js/desktop/src/preload.ts b/js/desktop/src/preload.ts index 2cad5ca..735d66c 100644 --- a/js/desktop/src/preload.ts +++ b/js/desktop/src/preload.ts @@ -83,6 +83,19 @@ contextBridge.exposeInMainWorld('electronScreenRecord', { }, }); +contextBridge.exposeInMainWorld('electronDeepLink', { + getPending: () => + ipcRenderer.invoke('deep-link:get-pending') as Promise, + onNavigate: (callback: (path: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, path: string) => + callback(path); + ipcRenderer.on('deep-link:navigate', handler); + return () => { + ipcRenderer.removeListener('deep-link:navigate', handler); + }; + }, +}); + contextBridge.exposeInMainWorld('electronLink', { openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url), });