Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ tailscale serve --bg 4600
# GitGood is now at https://<your-host>.<tailnet>.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/`).

Expand Down
108 changes: 93 additions & 15 deletions src/main/client.ts
Original file line number Diff line number Diff line change
@@ -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`,
Expand All @@ -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<string | null> {
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<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await fetchServerVersion(serverUrl)) return;
const { promise, resolve } = Promise.withResolvers<void>();
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)}`);
}
});
}

Expand Down Expand Up @@ -99,10 +160,10 @@ async function serverAllows(serverUrl: string, path: string): Promise<boolean> {
* 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<ApiMethods> = {
'app.clipboard.write': (text) => host.clipboardWrite(text),
'app.openExternal': async (url) => {
Expand All @@ -119,6 +180,22 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: ()
const unsupported = async (): Promise<never> => {
throw new Error('Only available when the GitGood server runs on this machine.');
};
const updateMethods: Partial<ApiMethods> = 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<void>) =>
Expand All @@ -129,14 +206,15 @@ export function registerClientIpc(serverUrl: string, store: Store, getWindow: ()
const native: Partial<ApiMethods> = local
? {
...always,
...updateMethods,
'app.chooseDirectory': (opts) => host.chooseDirectory(opts),
'app.chooseFile': (opts) => host.chooseFile(opts),
'app.chooseSavePath': (opts) => host.chooseSavePath(opts),
'app.openPath': confined((p) => host.openPath(p)),
'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<IpcResult<unknown> | null> => {
const fn = native[method as keyof ApiMethods] as ((...a: unknown[]) => Promise<unknown>) | undefined;
Expand Down
28 changes: 23 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions src/main/local-server.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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));
}
}

Loading
Loading