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'; 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')); } let mainWindow: BrowserWindow | null = null; let autoplayWindow: BrowserWindow | null = null; let huddleWindow: BrowserWindow | null = null; let screenRecordWindow: BrowserWindow | null = null; const createWindow = () => { mainWindow = new BrowserWindow({ width: 800, height: 600, resizable: true, fullscreenable: true, frame: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, }); 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; }); }; 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); 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) { huddleWindow.focus(); return; } huddleWindow = new BrowserWindow({ width: 1024, height: 768, resizable: true, fullscreenable: true, frame: true, title: 'Huddle', webPreferences: { preload: path.join(__dirname, 'preload.js'), }, }); if (HUDDLE_WINDOW_VITE_DEV_SERVER_URL) { huddleWindow.loadURL(HUDDLE_WINDOW_VITE_DEV_SERVER_URL); } else { huddleWindow.loadFile( path.join(__dirname, `../renderer/${HUDDLE_WINDOW_VITE_NAME}/index.html`), ); } huddleWindow.on('closed', () => { huddleWindow = null; }); }; 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); 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(); // Send connection data once the huddle window is ready huddleWindow?.webContents.once('did-finish-load', () => { huddleWindow?.webContents.send('huddle:connect', data); }); // If already loaded, send immediately if (!huddleWindow?.webContents.isLoading()) { huddleWindow?.webContents.send('huddle:connect', data); } }); ipcMain.on('window:close-huddle', () => { huddleWindow?.close(); }); ipcMain.handle('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 { return null; } } // --- Dock badge --- ipcMain.on('app:set-dock-badge', (_event, count: number) => { if (process.platform === 'darwin') { app.dock?.setBadge(count > 0 ? String(count) : ''); } }); ipcMain.handle('link:fetch-metadata', async (_event, url: string) => { if (typeof url !== 'string') return null; try { new URL(url); } catch { return null; } return fetchLinkMetadata(url); }); ipcMain.handle('link:open-external', async (_event, url: string) => { if (typeof url !== 'string') return; // Only allow http(s) URLs for security if (!url.startsWith('http://') && !url.startsWith('https://')) return; await shell.openExternal(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: ['https://orion.dev.flowy.live/*', '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(); }); // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); // 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.