Implement membership notifications and deep-link handling for Electron desktop app (#246)

* security: add cors for desktop app scheme

* Revert "security: add cors for desktop app scheme"

This reverts commit d450fced75.

* ignore tags

* add commands for windows dev

* cleanup unnecessary parts of main desktop process

* resolve lint errors

* add format command for go

* fix: send email on new member joining

Closes #228

* add proper deeplinking on desktop

The desktop app was merely focusing before, but now it will navigate to the proper route

* honor email notifications setting

* prevent sending email when no recipients
This commit was merged in pull request #246.
This commit is contained in:
Arjun Patel
2026-06-09 08:04:18 -07:00
committed by GitHub
parent 9184d002d3
commit c1f5b6c8c1
13 changed files with 384 additions and 79 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>;
};
+66 -61
View File
@@ -4,14 +4,11 @@ import {
desktopCapturer,
ipcMain,
screen,
session,
shell,
} from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { appConfig } from './config/env';
import { safeHandle } from './main/ipc-utils';
import { initSentryMain } from './main/sentry';
@@ -26,16 +23,11 @@ if (app.isPackaged) {
});
}
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
// Handle creating/removing shortcuts on Windows when installing/updating/uninstalling.
if (started) {
app.quit();
}
// Set the dock icon for development mode on macOS.
if (process.platform === 'darwin' && !app.isPackaged) {
app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png'));
}
// In dev, `LLINK_PROFILE=foo yarn start` spins up a second instance with an
// isolated userData dir so it can coexist with the default one (separate auth,
// cookies, leveldb locks).
@@ -44,12 +36,10 @@ if (devProfile) {
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
}
// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new
// process. The lock makes the losing instance quit and fires `second-instance` on
// the primary, so we focus the existing window instead of spawning a duplicate.
// macOS uses `open-url` instead and doesn't need this, but the lock is harmless.
// Skip the lock when running a named dev profile — those instances are meant to
// run alongside the default one.
// NOTE: on Windows/Linux, clicking a llink:// URL launches a new process. On macOS,
// `open-url` focuses existing instance of an application.
// Prevent running multiple instances of app, except when in development
if (!devProfile && !app.requestSingleInstanceLock()) {
app.quit();
}
@@ -63,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;
@@ -88,10 +80,7 @@ function hardenWindow(win: BrowserWindow) {
const isZoom =
cmdOrCtrl && (key === '=' || key === '+' || key === '-' || key === '0');
if (app.isPackaged && (isDevtools || isReload)) {
event.preventDefault();
}
if (isZoom) {
if (app.isPackaged && (isDevtools || isReload || isZoom)) {
event.preventDefault();
}
});
@@ -132,6 +121,9 @@ const createWindow = () => {
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
backgroundThrottling: false,
webSecurity: true,
contextIsolation: true,
nodeIntegration: false,
},
});
hardenWindow(mainWindow);
@@ -433,40 +425,46 @@ ipcMain.on(
},
);
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
// 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', () => {
// Allow CORS for API requests from the renderer process.
// The server doesn't handle OPTIONS preflight, so we intercept at the
// Electron network layer: inject CORS headers and return 200 for preflight.
session.defaultSession.webRequest.onHeadersReceived(
{ urls: [`${appConfig.orionUrl}/*`, 'https://storage.googleapis.com/*'] },
(details, callback) => {
const headers = { ...details.responseHeaders };
headers['access-control-allow-origin'] = ['*'];
headers['access-control-allow-headers'] = [
'Content-Type',
'Authorization',
];
headers['access-control-allow-methods'] = [
'GET',
'POST',
'PUT',
'DELETE',
'OPTIONS',
];
if (details.method === 'OPTIONS') {
callback({ responseHeaders: headers, statusLine: 'HTTP/1.1 200 OK' });
} else {
callback({ responseHeaders: headers });
}
},
);
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', () => {
@@ -489,20 +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();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.
// 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),
});