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:
@@ -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>
|
||||
|
||||
Vendored
+4
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user