add proper deeplinking on desktop

The desktop app was merely focusing before, but now it will navigate to the proper route
This commit is contained in:
Arjun Patel
2026-06-08 17:26:28 -07:00
parent edf493f733
commit e3bd3b7aa6
4 changed files with 89 additions and 9 deletions
+14
View File
@@ -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 (
<RouterShell>
<AutoplayNavigationListener />
<DeepLinkNavigationListener />
<InAppAutoplayCard />
<RouteErrorBoundary>
<Routes>
+4
View File
@@ -44,6 +44,10 @@ declare global {
stop: () => void;
onInit: (callback: () => void) => () => void;
};
electronDeepLink: {
getPending: () => Promise<string | null>;
onNavigate: (callback: (path: string) => void) => () => void;
};
electronLink: {
openExternal: (url: string) => Promise<void>;
};
+58 -9
View File
@@ -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;
});
+13
View File
@@ -83,6 +83,19 @@ contextBridge.exposeInMainWorld('electronScreenRecord', {
},
});
contextBridge.exposeInMainWorld('electronDeepLink', {
getPending: () =>
ipcRenderer.invoke('deep-link:get-pending') as Promise<string | null>,
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),
});