import { app, BrowserWindow, 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 type { LinkMetadata } from './lib/link-metadata'; import { appConfig } from './config/env'; import { logError } from './lib/errors'; 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/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). const devProfile = !app.isPackaged ? process.env.LLINK_PROFILE : undefined; 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. 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 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)) { event.preventDefault(); } if (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, }, }); 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(); } }); // --- Link metadata --- const metadataCache = new Map(); function getMetaContent(html: string, property: string): string | null { // Match both property="..." and name="..." attributes const regex = new RegExp( `]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`, 'i', ); const match = html.match(regex); return match?.[1] ?? match?.[2] ?? null; } function getTitle(html: string): string | null { const match = html.match(/]*>([^<]*)<\/title>/i); return match?.[1]?.trim() ?? null; } function getFavicon(html: string, baseUrl: string): string | null { const match = html.match(/]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']/i) ?? html.match(/]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']/i); if (!match?.[1]) { // Fall back to /favicon.ico try { const url = new URL(baseUrl); return `${url.protocol}//${url.host}/favicon.ico`; } catch { return null; } } try { return new URL(match[1], baseUrl).href; } catch { return match[1]; } } function resolveUrl(src: string | null, baseUrl: string): string | null { if (!src) return null; try { return new URL(src, baseUrl).href; } catch { return src; } } async function fetchLinkMetadata(url: string): Promise { const cached = metadataCache.get(url); if (cached) return cached; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; llink/1.0)', 'Accept': 'text/html', }, redirect: 'follow', }); clearTimeout(timeout); if (!response.ok) return null; // Only read the first ~50KB to get content const reader = response.body?.getReader(); if (!reader) return null; let html = ''; const decoder = new TextDecoder(); while (html.length < 50_000) { const { done, value } = await reader.read(); if (done) break; html += decoder.decode(value, { stream: true }); } reader.cancel(); const domain = new URL(url).hostname.replace(/^www\./, ''); const metadata: LinkMetadata = { url, title: getMetaContent(html, 'og:title') ?? getTitle(html), description: getMetaContent(html, 'og:description') ?? getMetaContent(html, 'description'), image: resolveUrl(getMetaContent(html, 'og:image'), url), favicon: getFavicon(html, url), domain, }; metadataCache.set(url, metadata); return metadata; } catch (err) { // Metadata is a progressive enhancement — keep the null contract, but log // so upstream failures (DNS, TLS, aborted fetches) aren't invisible. logError(err, { scope: 'link.fetchMetadata', url }); return null; } } // --- 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:fetch-metadata', async (_event, url) => { if (typeof url !== 'string') return null; try { new URL(url); } catch { return null; } return fetchLinkMetadata(url); }); 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); }, ); // 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. 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(); }); 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 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) => { event.preventDefault(); focusMainWindow(); }); // 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(); }); // 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.