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 `` 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; });