* 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
514 lines
14 KiB
TypeScript
514 lines
14 KiB
TypeScript
import {
|
|
app,
|
|
BrowserWindow,
|
|
desktopCapturer,
|
|
ipcMain,
|
|
screen,
|
|
shell,
|
|
} from 'electron';
|
|
import path from 'node:path';
|
|
import started from 'electron-squirrel-startup';
|
|
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
|
import { safeHandle } from './main/ipc-utils';
|
|
import { initSentryMain } from './main/sentry';
|
|
|
|
initSentryMain();
|
|
|
|
if (app.isPackaged) {
|
|
updateElectronApp({
|
|
updateSource: {
|
|
type: UpdateSourceType.StaticStorage,
|
|
baseUrl: `https://storage.googleapis.com/flowy-releases/llink/${process.platform}/${process.arch}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handle creating/removing shortcuts on Windows when installing/updating/uninstalling.
|
|
if (started) {
|
|
app.quit();
|
|
}
|
|
|
|
// 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).
|
|
const devProfile = !app.isPackaged ? process.env.LLINK_PROFILE : undefined;
|
|
if (devProfile) {
|
|
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Register llink:// as the default handler for this app. macOS also gets this
|
|
// declaratively via CFBundleURLTypes (forge.config.ts `protocols`); Windows relies
|
|
// on this runtime call (Squirrel doesn't register protocols on install).
|
|
if (!app.isDefaultProtocolClient('llink')) {
|
|
app.setAsDefaultProtocolClient('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;
|
|
|
|
function hardenWindow(win: BrowserWindow) {
|
|
const wc = win.webContents;
|
|
|
|
// Disable pinch / Cmd+scroll zoom; our layouts assume DPR=1.
|
|
wc.setVisualZoomLevelLimits(1, 1);
|
|
|
|
// Block devtools, reload, and zoom keyboard shortcuts in packaged builds.
|
|
wc.on('before-input-event', (event, input) => {
|
|
if (input.type !== 'keyDown') return;
|
|
const key = input.key.toLowerCase();
|
|
const cmdOrCtrl = input.meta || input.control;
|
|
|
|
const isDevtools =
|
|
key === 'f12' ||
|
|
(cmdOrCtrl && input.alt && key === 'i') ||
|
|
(cmdOrCtrl && input.shift && key === 'i') ||
|
|
(cmdOrCtrl && input.shift && key === 'c') ||
|
|
(cmdOrCtrl && input.shift && key === 'j');
|
|
const isReload = cmdOrCtrl && (key === 'r' || key === 'f5');
|
|
const isZoom =
|
|
cmdOrCtrl && (key === '=' || key === '+' || key === '-' || key === '0');
|
|
|
|
if (app.isPackaged && (isDevtools || isReload || isZoom)) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
// Route window.open / target=_blank through the OS browser instead of a new BrowserWindow.
|
|
wc.setWindowOpenHandler(({ url }) => {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
shell.openExternal(url);
|
|
}
|
|
return { action: 'deny' };
|
|
});
|
|
|
|
// Freeze the window to its initial origin — block any full-page navigation.
|
|
// In-app routing uses pushState / hash which don't trigger will-navigate.
|
|
wc.on('will-navigate', (event, url) => {
|
|
const currentUrl = wc.getURL();
|
|
if (!currentUrl) return;
|
|
try {
|
|
if (new URL(url).origin !== new URL(currentUrl).origin) {
|
|
event.preventDefault();
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
shell.openExternal(url);
|
|
}
|
|
}
|
|
} catch {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
}
|
|
|
|
const createWindow = () => {
|
|
mainWindow = new BrowserWindow({
|
|
width: 800,
|
|
height: 600,
|
|
resizable: true,
|
|
fullscreenable: true,
|
|
frame: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
backgroundThrottling: false,
|
|
webSecurity: true,
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
hardenWindow(mainWindow);
|
|
|
|
mainWindow.on('maximize', () => {
|
|
mainWindow?.webContents.send('window:maximize-changed', true);
|
|
});
|
|
mainWindow.on('unmaximize', () => {
|
|
mainWindow?.webContents.send('window:maximize-changed', false);
|
|
});
|
|
|
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
|
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
|
mainWindow.webContents.openDevTools();
|
|
} else {
|
|
mainWindow.loadFile(
|
|
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
|
|
);
|
|
}
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
app.quit();
|
|
});
|
|
};
|
|
|
|
const createAutoplayWindow = () => {
|
|
if (autoplayWindow) return;
|
|
|
|
autoplayWindow = new BrowserWindow({
|
|
width: 320,
|
|
height: 88,
|
|
resizable: false,
|
|
frame: false,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
focusable: false,
|
|
show: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
},
|
|
});
|
|
autoplayWindow.setVisibleOnAllWorkspaces(true);
|
|
hardenWindow(autoplayWindow);
|
|
|
|
if (AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL) {
|
|
autoplayWindow.loadURL(AUTOPLAY_WINDOW_VITE_DEV_SERVER_URL);
|
|
} else {
|
|
autoplayWindow.loadFile(
|
|
path.join(
|
|
__dirname,
|
|
`../renderer/${AUTOPLAY_WINDOW_VITE_NAME}/index.html`,
|
|
),
|
|
);
|
|
}
|
|
|
|
autoplayWindow.on('closed', () => {
|
|
autoplayWindow = null;
|
|
});
|
|
};
|
|
|
|
const createHuddleWindow = () => {
|
|
if (huddleWindow) return;
|
|
|
|
huddleWindow = new BrowserWindow({
|
|
width: 1024,
|
|
height: 768,
|
|
resizable: true,
|
|
fullscreenable: true,
|
|
frame: true,
|
|
title: 'Huddle',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
},
|
|
});
|
|
hardenWindow(huddleWindow);
|
|
|
|
huddleWindow.on('closed', () => {
|
|
huddleWindow = null;
|
|
});
|
|
};
|
|
|
|
// Connection data is passed via URL hash so it's available synchronously on
|
|
// renderer mount — avoids the IPC race where `huddle:connect` could be sent
|
|
// before React attached its listener.
|
|
const loadHuddleWindow = (data: { token: string; serverUrl: string }) => {
|
|
if (!huddleWindow) return;
|
|
const params = new URLSearchParams({
|
|
token: data.token,
|
|
serverUrl: data.serverUrl,
|
|
});
|
|
const hash = params.toString();
|
|
if (HUDDLE_WINDOW_VITE_DEV_SERVER_URL) {
|
|
huddleWindow.loadURL(`${HUDDLE_WINDOW_VITE_DEV_SERVER_URL}#${hash}`);
|
|
} else {
|
|
huddleWindow.loadFile(
|
|
path.join(__dirname, `../renderer/${HUDDLE_WINDOW_VITE_NAME}/index.html`),
|
|
{ hash },
|
|
);
|
|
}
|
|
};
|
|
|
|
function positionAutoplayWindow() {
|
|
if (!autoplayWindow) return;
|
|
const { width } = screen.getPrimaryDisplay().workAreaSize;
|
|
const [winW] = autoplayWindow.getSize();
|
|
autoplayWindow.setPosition(width - winW - 16, 16);
|
|
}
|
|
|
|
const createScreenRecordWindow = () => {
|
|
if (screenRecordWindow) return;
|
|
|
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
|
const winW = 240;
|
|
const winH = 48;
|
|
|
|
screenRecordWindow = new BrowserWindow({
|
|
width: winW,
|
|
height: winH,
|
|
x: Math.round((width - winW) / 2),
|
|
y: height - winH - 32,
|
|
resizable: false,
|
|
frame: false,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
focusable: true,
|
|
show: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
},
|
|
});
|
|
screenRecordWindow.setVisibleOnAllWorkspaces(true);
|
|
hardenWindow(screenRecordWindow);
|
|
|
|
if (SCREEN_RECORD_WINDOW_VITE_DEV_SERVER_URL) {
|
|
screenRecordWindow.loadURL(SCREEN_RECORD_WINDOW_VITE_DEV_SERVER_URL);
|
|
} else {
|
|
screenRecordWindow.loadFile(
|
|
path.join(
|
|
__dirname,
|
|
`../renderer/${SCREEN_RECORD_WINDOW_VITE_NAME}/index.html`,
|
|
),
|
|
);
|
|
}
|
|
|
|
screenRecordWindow.on('closed', () => {
|
|
screenRecordWindow = null;
|
|
});
|
|
};
|
|
|
|
// Window control IPC handlers
|
|
ipcMain.on('window:minimize', (event) => {
|
|
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
|
});
|
|
ipcMain.on('window:maximize', (event) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
if (win?.isMaximized()) {
|
|
win.unmaximize();
|
|
} else {
|
|
win?.maximize();
|
|
}
|
|
});
|
|
ipcMain.on('window:close', (event) => {
|
|
BrowserWindow.fromWebContents(event.sender)?.close();
|
|
});
|
|
ipcMain.on('window:fullscreen', (event) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
if (win) win.setFullScreen(!win.isFullScreen());
|
|
});
|
|
|
|
// Secondary window IPC handlers
|
|
ipcMain.on(
|
|
'window:open-huddle',
|
|
(_event, data: { token: string; serverUrl: string }) => {
|
|
createHuddleWindow();
|
|
loadHuddleWindow(data);
|
|
huddleWindow?.focus();
|
|
},
|
|
);
|
|
ipcMain.on('window:close-huddle', () => {
|
|
huddleWindow?.close();
|
|
});
|
|
|
|
safeHandle('screen:get-sources', async () => {
|
|
const sources = await desktopCapturer.getSources({
|
|
types: ['screen', 'window'],
|
|
thumbnailSize: { width: 320, height: 180 },
|
|
});
|
|
return sources.map((source) => ({
|
|
id: source.id,
|
|
name: source.name,
|
|
thumbnailDataUrl: source.thumbnail.toDataURL(),
|
|
appIconDataUrl: source.appIcon?.toDataURL() ?? null,
|
|
}));
|
|
});
|
|
|
|
// Screen recording IPC handlers
|
|
ipcMain.on('screen-record:start', () => {
|
|
createScreenRecordWindow();
|
|
if (!screenRecordWindow) return;
|
|
|
|
const winW = 240;
|
|
const winH = 48;
|
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
|
screenRecordWindow.setSize(winW, winH);
|
|
screenRecordWindow.setPosition(
|
|
Math.round((width - winW) / 2),
|
|
height - winH - 32,
|
|
);
|
|
|
|
const send = () => {
|
|
screenRecordWindow?.webContents.send('screen-record:init');
|
|
screenRecordWindow?.showInactive();
|
|
};
|
|
|
|
if (screenRecordWindow.webContents.isLoading()) {
|
|
screenRecordWindow.webContents.once('did-finish-load', send);
|
|
} else {
|
|
send();
|
|
}
|
|
});
|
|
ipcMain.on('screen-record:stop', () => {
|
|
mainWindow?.webContents.send('screen-record:stopped');
|
|
screenRecordWindow?.close();
|
|
mainWindow?.focus();
|
|
});
|
|
ipcMain.on('screen-record:cancel', () => {
|
|
screenRecordWindow?.close();
|
|
});
|
|
|
|
// Autoplay IPC handlers
|
|
ipcMain.on('autoplay:play', (_event, payload) => {
|
|
if (!autoplayWindow) createAutoplayWindow();
|
|
if (!autoplayWindow) return;
|
|
|
|
const isVideo =
|
|
typeof payload?.mimeType === 'string' &&
|
|
payload.mimeType.startsWith('video/');
|
|
autoplayWindow.setSize(320, isVideo ? 260 : 88);
|
|
positionAutoplayWindow();
|
|
|
|
autoplayWindow.webContents.send('autoplay:play', payload);
|
|
autoplayWindow.showInactive();
|
|
});
|
|
|
|
ipcMain.on('autoplay:dismiss', () => {
|
|
autoplayWindow?.webContents.send('autoplay:stop');
|
|
autoplayWindow?.hide();
|
|
});
|
|
|
|
ipcMain.on('autoplay:navigate', (_event, data) => {
|
|
autoplayWindow?.webContents.send('autoplay:stop');
|
|
autoplayWindow?.hide();
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('autoplay:navigate', data);
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
// --- Dock badge ---
|
|
|
|
ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
|
if (process.platform === 'darwin') {
|
|
app.dock?.setBadge(count > 0 ? String(count) : '');
|
|
}
|
|
});
|
|
|
|
safeHandle('app:get-version', () => app.getVersion());
|
|
|
|
safeHandle('link:open-external', async (_event, url) => {
|
|
if (typeof url !== 'string') return;
|
|
// Only allow http(s) URLs for security
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
|
|
await shell.openExternal(url);
|
|
});
|
|
|
|
// --- Attachment download ---
|
|
// Triggers a native download with save-as dialog. Cross-origin safe — unlike
|
|
// the web `<a download>` hack, which is ignored for cross-origin URLs.
|
|
ipcMain.on(
|
|
'attachment:download',
|
|
(event, payload: { url: string; filename?: string }) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
if (!win) return;
|
|
const { url, filename } = payload ?? {};
|
|
if (typeof url !== 'string') return;
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
|
|
|
|
const dlSession = win.webContents.session;
|
|
const onWillDownload = (
|
|
_e: Electron.Event,
|
|
item: Electron.DownloadItem,
|
|
) => {
|
|
if (filename) item.setSaveDialogOptions({ defaultPath: filename });
|
|
dlSession.removeListener('will-download', onWillDownload);
|
|
};
|
|
dlSession.on('will-download', onWillDownload);
|
|
win.webContents.downloadURL(url);
|
|
},
|
|
);
|
|
|
|
// 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', () => {
|
|
app.quit();
|
|
});
|
|
|
|
app.on('activate', () => {
|
|
if (!mainWindow) {
|
|
createWindow();
|
|
} else {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
function focusMainWindow() {
|
|
if (!mainWindow) return;
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
if (!mainWindow.isVisible()) mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
|
|
// macOS: fired on cold start and when app is already running.
|
|
app.on('open-url', (event, url) => {
|
|
event.preventDefault();
|
|
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, 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;
|
|
});
|