Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: convert Electron app to thin Tailnet client' (#186) from feat/electron-thin-client into main

scott a05fd49e a53b6ee7

+136 -111
-4
electron-builder.yml
··· 3 3 directories: 4 4 output: release 5 5 files: 6 - - dist/**/* 7 - - server/**/* 8 - - server.js 9 6 - package.json 10 - - node_modules/**/* 11 7 - electron/main.js 12 8 - electron/preload.js 13 9 - electron/package.json
+129 -100
electron/main.ts
··· 1 - import { app, BrowserWindow, shell, Menu } from 'electron'; 2 - import { spawn, type ChildProcess } from 'child_process'; 1 + import { app, BrowserWindow, shell, Menu, ipcMain, net } from 'electron'; 3 2 import path from 'path'; 4 - import { createServer } from 'net'; 5 - import { existsSync } from 'fs'; 3 + 4 + const TOOLS_URL = process.env.TOOLS_URL || 'https://tools.lobster-hake.ts.net'; 5 + const HEALTH_INTERVAL = 5_000; 6 6 7 7 let mainWindow: BrowserWindow | null = null; 8 - let serverProcess: ChildProcess | null = null; 9 - let serverPort = 0; 8 + let healthTimer: ReturnType<typeof setInterval> | null = null; 9 + let showingError = false; 10 + 11 + // --- Health check --- 12 + 13 + type HealthStatus = 'ok' | 'not-connected' | 'server-down'; 10 14 11 - /** Find a free TCP port by binding to port 0 and reading the assigned port. */ 12 - function getFreePort(): Promise<number> { 13 - return new Promise((resolve, reject) => { 14 - const srv = createServer(); 15 - srv.listen(0, '127.0.0.1', () => { 16 - const addr = srv.address(); 17 - if (addr && typeof addr === 'object') { 18 - const port = addr.port; 19 - srv.close(() => resolve(port)); 20 - } else { 21 - srv.close(() => reject(new Error('Could not determine port'))); 22 - } 15 + async function checkHealth(): Promise<HealthStatus> { 16 + try { 17 + const res = await net.fetch(`${TOOLS_URL}/health`, { 18 + signal: AbortSignal.timeout(5000), 23 19 }); 24 - srv.on('error', reject); 25 - }); 20 + return res.ok ? 'ok' : 'server-down'; 21 + } catch (err: unknown) { 22 + const msg = String(err); 23 + if ( 24 + msg.includes('ERR_NAME_NOT_RESOLVED') || 25 + msg.includes('ENOTFOUND') || 26 + msg.includes('getaddrinfo') 27 + ) { 28 + return 'not-connected'; 29 + } 30 + return 'server-down'; 31 + } 32 + } 33 + 34 + function startHealthMonitor(): void { 35 + if (healthTimer) return; 36 + healthTimer = setInterval(async () => { 37 + if (!showingError || !mainWindow) return; 38 + const status = await checkHealth(); 39 + if (status === 'ok') { 40 + showingError = false; 41 + stopHealthMonitor(); 42 + mainWindow.loadURL(TOOLS_URL); 43 + } 44 + }, HEALTH_INTERVAL); 26 45 } 27 46 28 - /** Wait for the Express server /health endpoint to respond. */ 29 - async function waitForServer(port: number, retries = 40): Promise<void> { 30 - for (let i = 0; i < retries; i++) { 31 - try { 32 - const res = await fetch(`http://127.0.0.1:${port}/health`); 33 - if (res.ok) return; 34 - } catch { /* server not ready yet */ } 35 - await new Promise(r => setTimeout(r, 250)); 47 + function stopHealthMonitor(): void { 48 + if (healthTimer) { 49 + clearInterval(healthTimer); 50 + healthTimer = null; 36 51 } 37 - throw new Error(`Server failed to start on port ${port} after ${retries} attempts`); 38 52 } 39 53 40 - /** Start the embedded Express server as a child process. */ 41 - async function startServer(): Promise<number> { 42 - const port = await getFreePort(); 54 + // --- Error page --- 43 55 44 - // Resolve paths relative to the app root. 45 - // In packaged app: process.resourcesPath/app.asar/ 46 - // In dev: project root 47 - const appRoot = app.isPackaged 48 - ? path.join(process.resourcesPath, 'app.asar') 49 - : path.resolve(__dirname, '..'); 56 + function errorHTML(type: 'not-connected' | 'server-down'): string { 57 + const title = 58 + type === 'not-connected' 59 + ? 'Can\u2019t Find the Tools Server' 60 + : 'Tools Server Unavailable'; 61 + const body = 62 + type === 'not-connected' 63 + ? 'Make sure <strong>Tailscale</strong> is running and you\u2019re connected to the right network.' 64 + : 'The server isn\u2019t responding. It may be restarting \u2014 retrying automatically.'; 65 + 66 + return `<!DOCTYPE html> 67 + <html lang="en"><head><meta charset="utf-8"><title>Tools</title> 68 + <style> 69 + *{margin:0;padding:0;box-sizing:border-box} 70 + body{font-family:-apple-system,BlinkMacSystemFont,'SF Mono','Fira Code',monospace; 71 + background:#111;color:#999;display:flex;align-items:center;justify-content:center; 72 + height:100vh;-webkit-app-region:drag;user-select:none} 73 + .c{text-align:center;max-width:420px;padding:2rem} 74 + h1{font-size:1rem;color:#ccc;font-weight:500;margin-bottom:.75rem;letter-spacing:-.01em} 75 + p{font-size:.82rem;line-height:1.7;color:#666;margin-bottom:1.5rem} 76 + p strong{color:#888;font-weight:500} 77 + .s{font-size:.7rem;color:#444;margin-bottom:1.5rem} 78 + .dot{display:inline-block;animation:p 1.5s infinite} 79 + @keyframes p{0%,100%{opacity:.3}50%{opacity:1}} 80 + button{-webkit-app-region:no-drag;background:0 0;border:1px solid #2a2a2a;color:#777; 81 + padding:.45rem 1.4rem;font-family:inherit;font-size:.78rem;border-radius:4px; 82 + cursor:pointer;transition:all .15s} 83 + button:hover{border-color:#444;color:#bbb} 84 + .u{font-size:.65rem;color:#333;margin-top:1.5rem} 85 + </style></head> 86 + <body><div class="c"> 87 + <h1>${title}</h1> 88 + <p>${body}</p> 89 + <button onclick="window.electronAPI?.retry()">Retry Now</button> 90 + <div class="s"><span class="dot">&bull;</span> Retrying automatically</div> 91 + <div class="u">${TOOLS_URL}</div> 92 + </div></body></html>`; 93 + } 50 94 51 - const serverEntry = path.join(appRoot, 'server', 'index.ts'); 52 - const serverEntryJs = path.join(appRoot, 'server.js'); 95 + function showError(type: 'not-connected' | 'server-down'): void { 96 + if (!mainWindow) return; 97 + showingError = true; 98 + mainWindow.loadURL( 99 + `data:text/html;charset=utf-8,${encodeURIComponent(errorHTML(type))}`, 100 + ); 101 + startHealthMonitor(); 102 + } 53 103 54 - // Use the compiled server.js shim if available (packaged), else tsx for dev 55 - const useCompiledServer = app.isPackaged && existsSync(serverEntryJs); 56 - const dataDir = path.join(app.getPath('userData'), 'data'); 104 + // --- IPC --- 57 105 58 - if (useCompiledServer) { 59 - serverProcess = spawn(process.execPath, [serverEntryJs], { 60 - env: { 61 - ...process.env, 62 - PORT: String(port), 63 - DATA_DIR: dataDir, 64 - NODE_ENV: 'production', 65 - }, 66 - stdio: ['ignore', 'pipe', 'pipe'], 67 - cwd: appRoot, 68 - }); 69 - } else { 70 - // Dev mode: use tsx to run TypeScript directly 71 - const tsxBin = path.join(appRoot, 'node_modules', '.bin', 'tsx'); 72 - serverProcess = spawn(tsxBin, [serverEntry], { 73 - env: { 74 - ...process.env, 75 - PORT: String(port), 76 - DATA_DIR: dataDir, 77 - NODE_ENV: 'development', 78 - }, 79 - stdio: ['ignore', 'pipe', 'pipe'], 80 - cwd: appRoot, 81 - }); 106 + ipcMain.on('retry-connection', async () => { 107 + if (!mainWindow || !showingError) return; 108 + const status = await checkHealth(); 109 + if (status === 'ok') { 110 + showingError = false; 111 + stopHealthMonitor(); 112 + mainWindow.loadURL(TOOLS_URL); 82 113 } 83 - 84 - serverProcess.stdout?.on('data', (chunk: Buffer) => { 85 - process.stdout.write(`[server] ${chunk}`); 86 - }); 87 - serverProcess.stderr?.on('data', (chunk: Buffer) => { 88 - process.stderr.write(`[server] ${chunk}`); 89 - }); 90 - serverProcess.on('exit', (code) => { 91 - console.log(`Server exited with code ${code}`); 92 - serverProcess = null; 93 - }); 114 + }); 94 115 95 - await waitForServer(port); 96 - return port; 97 - } 116 + // --- Window --- 98 117 99 118 function createWindow(): void { 100 119 mainWindow = new BrowserWindow({ ··· 102 121 height: 900, 103 122 minWidth: 800, 104 123 minHeight: 600, 124 + backgroundColor: '#111', 105 125 titleBarStyle: 'hiddenInset', 106 126 trafficLightPosition: { x: 16, y: 16 }, 107 127 webPreferences: { ··· 112 132 }, 113 133 }); 114 134 115 - mainWindow.loadURL(`http://127.0.0.1:${serverPort}`); 135 + // Handle navigation failures (network errors, DNS failures, etc.) 136 + mainWindow.webContents.on( 137 + 'did-fail-load', 138 + (_event, errorCode, _errorDescription, _url, isMainFrame) => { 139 + if (!isMainFrame) return; 140 + if (errorCode === -3) return; // ERR_ABORTED — navigation cancelled, ignore 141 + 142 + const dnsErrors = [-105, -109, -118, -137]; 143 + showError(dnsErrors.includes(errorCode) ? 'not-connected' : 'server-down'); 144 + }, 145 + ); 116 146 117 147 // Open external links in the default browser 118 148 mainWindow.webContents.setWindowOpenHandler(({ url }) => { 119 - if (url.startsWith('http://127.0.0.1')) { 120 - return { action: 'allow' }; 121 - } 149 + if (url.startsWith(TOOLS_URL)) return { action: 'allow' }; 122 150 shell.openExternal(url); 123 151 return { action: 'deny' }; 124 152 }); 125 153 126 154 mainWindow.on('closed', () => { 127 155 mainWindow = null; 156 + stopHealthMonitor(); 128 157 }); 129 158 } 159 + 160 + // --- Menu --- 130 161 131 162 function buildMenu(): void { 132 163 const template: Electron.MenuItemConstructorOptions[] = [ ··· 148 179 { 149 180 label: 'New Document', 150 181 accelerator: 'CmdOrCtrl+N', 151 - click: () => mainWindow?.loadURL(`http://127.0.0.1:${serverPort}`), 182 + click: () => mainWindow?.loadURL(TOOLS_URL), 152 183 }, 153 184 { type: 'separator' }, 154 185 { role: 'close' }, ··· 194 225 Menu.setApplicationMenu(Menu.buildFromTemplate(template)); 195 226 } 196 227 228 + // --- App lifecycle --- 229 + 197 230 app.whenReady().then(async () => { 198 231 buildMenu(); 232 + createWindow(); 199 233 200 - try { 201 - serverPort = await startServer(); 202 - console.log(`Server started on port ${serverPort}`); 203 - createWindow(); 204 - } catch (err) { 205 - console.error('Failed to start server:', err); 206 - app.quit(); 234 + const status = await checkHealth(); 235 + if (status === 'ok') { 236 + mainWindow?.loadURL(TOOLS_URL); 237 + } else { 238 + showError(status); 207 239 } 208 240 209 241 app.on('activate', () => { 210 242 if (BrowserWindow.getAllWindows().length === 0) { 211 243 createWindow(); 244 + checkHealth().then((s) => { 245 + if (s === 'ok') mainWindow?.loadURL(TOOLS_URL); 246 + else showError(s); 247 + }); 212 248 } 213 249 }); 214 250 }); ··· 218 254 app.quit(); 219 255 } 220 256 }); 221 - 222 - app.on('before-quit', () => { 223 - if (serverProcess) { 224 - serverProcess.kill(); 225 - serverProcess = null; 226 - } 227 - });
+2 -2
electron/preload.ts
··· 1 - import { contextBridge } from 'electron'; 1 + import { contextBridge, ipcRenderer } from 'electron'; 2 2 3 - // Expose a minimal API to the renderer so the web app can detect Electron 4 3 contextBridge.exposeInMainWorld('electronAPI', { 5 4 isElectron: true, 6 5 platform: process.platform, 6 + retry: () => ipcRenderer.send('retry-connection'), 7 7 });
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.14.5", 3 + "version": "0.15.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.14.5", 9 + "version": "0.15.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-collaboration": "^2.11.0",
+3 -3
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.14.5", 3 + "version": "0.15.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js", ··· 13 13 "e2e": "playwright test", 14 14 "typecheck": "tsc --noEmit", 15 15 "electron:compile": "tsc -p electron/tsconfig.json && cp .electron-build/main.js .electron-build/preload.js electron/", 16 - "electron:dev": "npm run build && npm run electron:compile && electron .", 17 - "electron:build": "npm run build && npm run electron:compile && electron-builder --mac" 16 + "electron:dev": "npm run electron:compile && electron .", 17 + "electron:build": "npm run electron:compile && electron-builder --mac" 18 18 }, 19 19 "dependencies": { 20 20 "@tiptap/core": "^2.11.0",