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.
440 lines
12 KiB
TypeScript
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.
|