Files
llink/js/src/main.ts
T
talksik f91068a4c1 fix: freezes during longer screen recordings
Closes #131

Fundamentally, we cannot afford to composite webcam and screen on the
client. We should create two streams and then combine them with ffmpeg
processing in the backend / orion.
2026-04-09 08:31:23 -07:00

440 lines
12 KiB
TypeScript

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<string, LinkMetadata>();
function getMetaContent(html: string, property: string): string | null {
// Match both property="..." and name="..." attributes
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']|<meta[^>]*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[^>]*>([^<]*)<\/title>/i);
return match?.[1]?.trim() ?? null;
}
function getFavicon(html: string, baseUrl: string): string | null {
const match = html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']/i)
?? html.match(/<link[^>]*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<LinkMetadata | null> {
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 <head> 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.