* add token endpoint for livekit * fix: invalid type passed to hook * fix: inject livekit env variables for orion * fix: show controls indicator above stream # shortcut * return livekit server url from api * simple huddle implementation with streams * set human name in livekit room context * simplify deployment tooling * join huddle with audio automatically * feat: show when there is an active huddle This introduces a webhook which listens to events from livekit and updates our firestore stream particle. It keeps the client simple, reacting to changes to firestore docs. * use headphones icon for huddles * fix: screenshare not working in electron The default VideoConference component from livekit doesn't support screenshare in electron. This attempts to compose our own layout with livekit components ourselves and introduces our own flow for screenshare.
365 lines
10 KiB
TypeScript
365 lines
10 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;
|
|
|
|
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'),
|
|
},
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
// 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,
|
|
}));
|
|
});
|
|
|
|
// 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?.hide();
|
|
});
|
|
|
|
ipcMain.on('autoplay:navigate', (_event, data) => {
|
|
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.
|