From 03afc5a27f7c295f5f4eb1148e89499ba4172ab2 Mon Sep 17 00:00:00 2001 From: Erwin Wee Date: Thu, 24 Sep 2026 17:39:37 +0800 Subject: [PATCH] feat(server): desktop-managed local server that updates with the app - File > Run GitGood server in the background (Linux, packaged): installs a systemd user service that runs the server bundled in this app via ELECTRON_RUN_AS_NODE, copies this desktop's settings/repositories into an empty server data dir, and switches the desktop to client mode. - On every client-mode launch the desktop rewrites that unit to point at itself (AppImage updates rename the file) and restarts the server when the unit changed or its version differs. Units from npm run install:service are never touched. - The desktop runs its own updater in client mode (it had none), answering app.update.* natively, so updating the desktop updates the server. - The version-mismatch dialog offers an action: Restart server (managed), Copy command (checkout), Reload (remote), or Check for updates (desktop older). --- README.md | 2 +- src/main/client.ts | 108 +++++++++++++++++++++++---- src/main/index.ts | 28 +++++-- src/main/local-server.ts | 151 ++++++++++++++++++++++++++++++++++++++ src/main/menu.ts | 8 +- test/local-server.test.ts | 16 ++++ 6 files changed, 291 insertions(+), 22 deletions(-) create mode 100644 src/main/local-server.ts create mode 100644 test/local-server.test.ts diff --git a/README.md b/README.md index 629507f..08c1b83 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ tailscale serve --bg 4600 # GitGood is now at https://..ts.net ``` -To keep it running on Linux, `npm run install:service` installs and starts a systemd user service (`gitgood-server`) for this checkout; set environment variables with `systemctl --user edit gitgood-server`, and re-run it after moving the checkout. To start from your desktop's repositories and settings, copy `settings.json`, `repositories.json` and `state.json` into `~/.config/gitgood-server` once, with the server stopped. +To let the packaged desktop manage that service, choose **File → Run GitGood server in the background…**. It installs a user service that is auto-updated with the desktop and rewritten on each launch (including after AppImage updates), then uses `http://127.0.0.1:4600` and `tailscale serve`. Stop it with `systemctl --user disable --now gitgood-server`, then delete `~/.config/gitgood/server-url`. Checkout services installed by `npm run install:service` are unmanaged and are never rewritten automatically. Installed copies of GitGood ship the server too; run it with the app's own runtime, e.g. `ELECTRON_RUN_AS_NODE=1 /path/to/gitgood /path/to/resources/app.asar/out/server/index.mjs` (for the Linux AppImage, `--appimage-extract` it first and use `squashfs-root/`). diff --git a/src/main/client.ts b/src/main/client.ts index 641dc0f..ce337ca 100644 --- a/src/main/client.ts +++ b/src/main/client.ts @@ -1,13 +1,16 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { dialog, ipcMain, nativeTheme, Notification, type BrowserWindow } from 'electron'; +import { clipboard, dialog, ipcMain, nativeTheme, Notification, shell, type BrowserWindow } from 'electron'; import { IPC_EVENT_CHANNEL, IPC_INVOKE_CHANNEL, type ApiMethods } from '@shared/ipc'; import type { InboxItem, InboxState, IpcResult } from '@shared/types'; import { applyInboxBadge } from './badge'; import { ElectronHost } from './host/electron-host'; import { sendEvent } from './ipc'; +import { ensureManagedServer, readUnit } from './local-server'; import { log } from './logger'; import type { Store } from './store'; +import { compareVersions } from './update/update-core'; +import type { Updater } from './update/updater'; /** * The GitGood server this desktop app is a client of: `GITGOOD_SERVER_URL`, @@ -27,31 +30,89 @@ export function clientServerUrl(userData: string): string | null { return raw.trim() ? new URL(raw.trim()).origin : null; } +export function isLocalServerUrl(serverUrl: string): boolean { + return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(serverUrl).hostname); +} + +/** Fetches the server version without making startup wait on an unavailable service. */ +export async function fetchServerVersion(serverUrl: string, timeoutMs = 1500): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${serverUrl}/version`, { signal: controller.signal }); + if (!response.ok) return null; + const body: unknown = await response.json(); + return body && typeof body === 'object' && 'version' in body && typeof body.version === 'string' ? body.version : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** Waits briefly after restarting the managed server so the first window load sees a live endpoint. */ +export async function waitForServer(serverUrl: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await fetchServerVersion(serverUrl)) return; + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, 250); + await promise; + } +} + /** * Warns once when this desktop build and the server differ: the window runs the * server's renderer against this build's native IPC, so a skew can leave * features half-working. Called on every load; loads that can't reach the * server (the retry page) are ignored. */ -export function watchServerVersion(serverUrl: string, win: BrowserWindow, desktopVersion: string): void { +export function watchServerVersion(serverUrl: string, win: BrowserWindow, desktopVersion: string, updater?: Updater): void { let warned = false; win.webContents.on('did-finish-load', async () => { if (warned) return; - let body: unknown; - try { - body = await (await fetch(`${serverUrl}/version`)).json(); - } catch { - return; - } - const server = body && typeof body === 'object' && 'version' in body && typeof body.version === 'string' ? body.version : null; - if (!server || server === desktopVersion || warned || win.isDestroyed()) return; + const server = await fetchServerVersion(serverUrl); + if (!server || compareVersions(server, desktopVersion) === 0 || warned || win.isDestroyed()) return; warned = true; log.warn(`Desktop ${desktopVersion} is using GitGood server ${server}`); - void dialog.showMessageBox(win, { + const local = isLocalServerUrl(serverUrl); + const serverOlder = compareVersions(server, desktopVersion) < 0; + const managed = local && readUnit().managed; + const action = managed ? 'Restart server' : serverOlder && local ? 'Copy command' : serverOlder ? 'Reload' : 'Check for updates'; + const detail = managed + ? 'The local managed server does not match this desktop. Restart it to use the bundled server.' + : serverOlder && local + ? 'Update the server from its checkout, then restart it with:\n\ngit pull && npm run build && systemctl --user restart gitgood-server' + : serverOlder + ? `Update GitGood on ${new URL(serverUrl).hostname}, then reload.` + : 'This desktop is older than the server. Check for a desktop update.'; + const result = await dialog.showMessageBox(win, { type: 'warning', message: `GitGood ${desktopVersion} is connected to a GitGood server running ${server}.`, - detail: 'Update or rebuild the one that is behind so both run the same version; until then some features may not work.', + detail, + buttons: [action, 'Later'], + defaultId: 0, + cancelId: 1, }); + if (result.response !== 0 || win.isDestroyed()) return; + try { + if (action === 'Restart server') { + await ensureManagedServer(server, desktopVersion); + if (!win.isDestroyed()) win.reload(); + } else if (action === 'Check for updates' && updater) { + const state = await updater.checkNow(true); + if (state.status === 'available') { + if (updater.canAutoUpdate) await updater.startDownload(); + else await shell.openExternal(state.url); + } + } else if (action === 'Copy command') { + clipboard.writeText('git pull && npm run build && systemctl --user restart gitgood-server'); + } else { + win.reload(); + } + } catch (err) { + log.warn(`Version mismatch action failed: ${err instanceof Error ? err.message : String(err)}`); + } }); } @@ -99,10 +160,10 @@ async function serverAllows(serverUrl: string, path: string): Promise { * native capabilities answered here. The bridge sends every call here first * and falls back to its web path when this resolves null. */ -export function registerClientIpc(serverUrl: string, store: Store, getWindow: () => BrowserWindow | null): void { +export function registerClientIpc(serverUrl: string, store: Store, getWindow: () => BrowserWindow | null, updater?: Updater): void { const host = new ElectronHost(getWindow); // Paths the renderer holds are the server's: native file dialogs and shell actions only make sense when it shares this machine's filesystem. - const local = ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(serverUrl).hostname); + const local = isLocalServerUrl(serverUrl); const always: Partial = { 'app.clipboard.write': (text) => host.clipboardWrite(text), 'app.openExternal': async (url) => { @@ -119,6 +180,22 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: () const unsupported = async (): Promise => { throw new Error('Only available when the GitGood server runs on this machine.'); }; + const updateMethods: Partial = updater + ? { + 'app.update.state': async () => updater.getState(), + 'app.update.check': async () => updater.checkNow(true), + 'app.update.download': async () => { + if (updater.canAutoUpdate) { + await updater.startDownload(); + return; + } + const state = updater.getState(); + if (state.status === 'available') await host.openExternal(state.url); + }, + 'app.update.install': async () => updater.quitAndInstall(), + 'app.update.dismiss': async (version) => updater.dismiss(version), + } + : {}; // The page is the server's, so its paths get the server's confinement before the desktop acts on them natively. const confined = (fn: (path: string) => Promise) => @@ -129,6 +206,7 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: () const native: Partial = local ? { ...always, + ...updateMethods, 'app.chooseDirectory': (opts) => host.chooseDirectory(opts), 'app.chooseFile': (opts) => host.chooseFile(opts), 'app.chooseSavePath': (opts) => host.chooseSavePath(opts), @@ -136,7 +214,7 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: () 'app.showItemInFolder': confined((p) => host.showItemInFolder(p)), 'app.moveToTrash': confined((p) => host.trashItem(p)), } - : { ...always, 'app.openInEditor': unsupported, 'app.openInShell': unsupported }; + : { ...always, ...updateMethods, 'app.openInEditor': unsupported, 'app.openInShell': unsupported }; ipcMain.handle(IPC_INVOKE_CHANNEL, async (_event, method: string, ...args: unknown[]): Promise | null> => { const fn = native[method as keyof ApiMethods] as ((...a: unknown[]) => Promise) | undefined; diff --git a/src/main/index.ts b/src/main/index.ts index 0733a38..d9a1e74 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,7 +14,8 @@ import { ReviewService } from './ai/review'; import { SplitterService } from './ai/splitter'; import { TriageService } from './ai/triage'; import { applyInboxBadge } from './badge'; -import { clientServerUrl, registerClientIpc, showInboxNotification, watchServerVersion } from './client'; +import { clientServerUrl, fetchServerVersion, isLocalServerUrl, registerClientIpc, showInboxNotification, waitForServer, watchServerVersion } from './client'; +import { ensureManagedServer, setUpManagedServer } from './local-server'; import { GitClient } from './git/git'; import { fetch as gitFetch } from './git/operations'; import { GhClient } from './gh/gh'; @@ -121,13 +122,30 @@ if (!gotLock) { return; } if (serverUrl) { - // Client mode: settings, repositories and every service live on the server; this store only keeps window bounds and zoom. + // Client mode keeps the repository on the server, but the desktop still owns its installer update channel. log.info(`Client mode: using the GitGood server at ${serverUrl}`); - registerClientIpc(serverUrl, store, getWindow); + const disabledEnv: DisabledEnv = { isPackaged: app.isPackaged, platform: process.platform, portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR, appImagePath: process.env.APPIMAGE, appImageWritable: appImageWritable(process.env.APPIMAGE) }; + const updateProvider = new ElectronUpdaterProvider(autoUpdater as unknown as ElectronAutoUpdater, () => store.getSettings().updateChannel); + const updater = new Updater(store, updateProvider, (state) => { + log.info(`Update state: ${state.status}${state.status === 'available' ? ` (${state.version})` : ''}`); + sendEvent(getWindow(), 'app.update.changed', state); + }, { getVersion: () => app.getVersion(), manualUrl: RELEASES_URL, disabledEnv, isPerMachineInstall: isPerMachineInstall(process.execPath, process.platform) }); + updater.start(); + registerClientIpc(serverUrl, store, getWindow, updater); + if (isLocalServerUrl(serverUrl)) { + const serverVersion = await fetchServerVersion(serverUrl); + // A broken service must not stop the window opening: its retry page and the mismatch dialog still apply. + try { + const status = await ensureManagedServer(serverVersion, app.getVersion()); + if (status === 'restarted') await waitForServer(serverUrl); + } catch (err) { + log.warn(`Could not update the managed GitGood server: ${(err as Error).message}`); + } + } Menu.setApplicationMenu(buildMenu(getWindow)); const openWindow = () => { mainWindow = createMainWindow(store, undefined, serverUrl); - watchServerVersion(serverUrl, mainWindow, app.getVersion()); + watchServerVersion(serverUrl, mainWindow, app.getVersion(), updater); mainWindow.on('closed', () => { mainWindow = null; }); @@ -185,7 +203,7 @@ if (!gotLock) { }, { getVersion: () => app.getVersion(), manualUrl: RELEASES_URL, disabledEnv, isPerMachineInstall: isPerMachineInstall(process.execPath, process.platform) }); const deps: HandlerDeps = { store, tools, git, gh, repos, resolver, review, splitter, triage, prDraft, rebasePlan, releaseNotes, explain, errorExplain, inbox, settingsSync, updater, nlPalette, watchedFolders, host, emit: bus.emit, busy }; registerIpc(deps); - Menu.setApplicationMenu(buildMenu(getWindow)); + Menu.setApplicationMenu(buildMenu(getWindow, { showManagedServer: process.platform === 'linux' && app.isPackaged, onManagedServer: () => void setUpManagedServer(userData) })); /** Desktop notification for a freshly-arrived inbox item (only while the window is unfocused; see shouldNotifyInboxItem for the per-category gating). */ function notifyNewInboxItems(items: InboxItem[]): void { diff --git a/src/main/local-server.ts b/src/main/local-server.ts new file mode 100644 index 0000000..a8c4bab --- /dev/null +++ b/src/main/local-server.ts @@ -0,0 +1,151 @@ +import { execFile } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { app, dialog } from 'electron'; + +const execFileAsync = promisify(execFile); +const UNIT_NAME = 'gitgood-server'; +const UNIT_MARKER = '# Managed by the GitGood desktop app; rewritten on launch.'; +const SERVER_URL = 'http://127.0.0.1:4600'; + +export interface UnitStatus { + exists: boolean; + managed: boolean; + text: string | null; +} + +/** + * The service starts the bundled server through Electron's Node mode. The + * trailing `-- --no-sandbox` stops the AppImage's AppRun from prepending + * `--no-sandbox` (which Node mode rejects) on systems without user + * namespaces; after `--` Node treats it as a plain script argument. + */ +export function managedUnit(exe: string): string { + const entry = "import(require('node:url').pathToFileURL(require('node:path').join(process.resourcesPath,'app.asar','out','server','index.mjs')).href)"; + // systemd splits unquoted words and expands % specifiers. + const quotedExe = `"${exe.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`; + return `[Unit] +Description=GitGood headless server +After=network.target +${UNIT_MARKER} + +[Service] +Type=simple +Environment=ELECTRON_RUN_AS_NODE=1 +ExecStart=${quotedExe} -e "${entry}" -- --no-sandbox +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target +`; +} + +function unitPath(): string { + return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user', `${UNIT_NAME}.service`); +} + +function currentExecutable(): string { + return process.env.APPIMAGE || process.execPath; +} + +function supported(): boolean { + return process.platform === 'linux' && app.isPackaged; +} + +export function readUnit(): UnitStatus { + const file = unitPath(); + if (!existsSync(file)) return { exists: false, managed: false, text: null }; + try { + const text = readFileSync(file, 'utf8'); + return { exists: true, managed: text.includes(UNIT_MARKER), text }; + } catch { + return { exists: true, managed: false, text: null }; + } +} + +async function systemctl(...args: string[]): Promise { + await execFileAsync('systemctl', ['--user', ...args]); +} + +/** Rewrites only units previously installed by this desktop, then restarts a skewed server. */ +export async function ensureManagedServer(serverVersion: string | null, desktopVersion: string): Promise<'unmanaged' | 'ok' | 'restarted'> { + if (!supported()) return 'ok'; + const unit = readUnit(); + if (!unit.managed) return 'unmanaged'; + + const expected = managedUnit(currentExecutable()); + const changed = unit.text !== expected; + if (changed) { + const file = unitPath(); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, expected); + await systemctl('daemon-reload'); + } + if (changed || serverVersion !== desktopVersion) { + await systemctl('restart', UNIT_NAME); + return 'restarted'; + } + return 'ok'; +} + +function copyInitialData(userData: string, serverData: string): void { + const settings = join(serverData, 'settings.json'); + if (existsSync(settings)) return; + for (const name of ['settings.json', 'repositories.json', 'state.json']) { + const source = join(userData, name); + const target = join(serverData, name); + if (existsSync(source) && !existsSync(target)) copyFileSync(source, target); + } +} + +/** Installs the desktop-managed service and switches this launch to client mode. */ +export async function setUpManagedServer(userData: string): Promise { + if (!supported()) return; + try { + const confirmation = await dialog.showMessageBox({ + type: 'info', + title: 'Run GitGood server in the background', + message: 'Run GitGood server in the background?', + detail: 'This installs a background service that keeps GitGood reachable at http://127.0.0.1:4600 and over tailscale serve. GitGood will use it and update it automatically.', + buttons: ['Cancel', 'Install'], + defaultId: 1, + cancelId: 0, + }); + if (confirmation.response !== 1) return; + + const existing = readUnit(); + if (existing.exists && !existing.managed) { + const replace = await dialog.showMessageBox({ + type: 'warning', + title: 'Replace GitGood server service?', + message: 'An unmanaged gitgood-server.service already exists.', + detail: 'It may be running from a source checkout. Replace it with the desktop-managed service?', + buttons: ['Cancel', 'Replace'], + defaultId: 1, + cancelId: 0, + }); + if (replace.response !== 1) return; + } + + const serverData = join(homedir(), '.config', 'gitgood-server'); + mkdirSync(serverData, { recursive: true }); + copyInitialData(userData, serverData); + + const file = unitPath(); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, managedUnit(currentExecutable())); + await systemctl('daemon-reload'); + await systemctl('enable', '--now', UNIT_NAME); + await systemctl('restart', UNIT_NAME); + + writeFileSync(join(userData, 'server-url'), `${SERVER_URL}\n`); + app.relaunch(); + app.exit(0); + } catch (err) { + dialog.showErrorBox('GitGood server', err instanceof Error ? err.message : String(err)); + } +} + diff --git a/src/main/menu.ts b/src/main/menu.ts index e0cadcb..6ea641e 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -3,6 +3,11 @@ import { sendEvent } from './ipc'; const isMac = process.platform === 'darwin'; +interface MenuOptions { + showManagedServer?: boolean; + onManagedServer?: () => void; +} + function action(label: string, id: string, accelerator?: string, args?: unknown): MenuItemConstructorOptions { return { label, @@ -14,7 +19,7 @@ function action(label: string, id: string, accelerator?: string, args?: unknown) }; } -export function buildMenu(getWindow: () => BrowserWindow | null): Menu { +export function buildMenu(getWindow: () => BrowserWindow | null, options: MenuOptions = {}): Menu { const template: MenuItemConstructorOptions[] = []; if (isMac) { @@ -47,6 +52,7 @@ export function buildMenu(getWindow: () => BrowserWindow | null): Menu { { type: 'separator' }, action('Export Settings…', 'export-settings'), action('Import Settings…', 'import-settings'), + ...(options.showManagedServer ? [{ type: 'separator' as const }, { label: 'Run GitGood server in the background…', click: () => options.onManagedServer?.() }] : []), { type: 'separator' }, ...(isMac ? [] : [action('Options…', 'settings', 'CmdOrCtrl+,'), { type: 'separator' } as MenuItemConstructorOptions]), isMac ? { role: 'close' } : { role: 'quit', label: 'E&xit' }, diff --git a/test/local-server.test.ts b/test/local-server.test.ts new file mode 100644 index 0000000..7a7cf80 --- /dev/null +++ b/test/local-server.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { managedUnit } from '../src/main/local-server'; + +describe('managedUnit', () => { + it('starts the bundled server in Node mode, immune to the AppImage adding --no-sandbox', () => { + const unit = managedUnit('/opt/GitGood/gitgood'); + expect(unit).toContain('# Managed by the GitGood desktop app; rewritten on launch.'); + expect(unit).toContain('Environment=ELECTRON_RUN_AS_NODE=1'); + expect(unit).toMatch(/^ExecStart="\/opt\/GitGood\/gitgood" -e ".*process\.resourcesPath.*index\.mjs.*" -- --no-sandbox$/m); + }); + + it('quotes an executable path with spaces and escapes systemd specifiers', () => { + const unit = managedUnit('/home/me/Apps/GitGood 100%.AppImage'); + expect(unit).toContain('ExecStart="/home/me/Apps/GitGood 100%%.AppImage" -e '); + }); +});