forked from boos/BoosAPI
feat: implement macOS notarization and ad-hoc signing in afterSign script
feat: add run-server script to manage server execution and compatibility checks feat: create main application logic for desktop with window management, server handling, and auto-updater test: add unit tests for desktop runtime helpers feat: implement desktop server environment configuration and health check test: add tests for server configuration and desktop routes feat: add native module guard for better-sqlite3 compatibility test: add tests for native module guard functionality test: add comprehensive tests for account verification feedback handling feat: implement account verification feedback helpers chore: add TypeScript configuration for desktop builds
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog,
|
||||
nativeImage,
|
||||
shell,
|
||||
} from 'electron';
|
||||
import log from 'electron-log';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import getPort, { portNumbers } from 'get-port';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopHealthUrl,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverUrl = '';
|
||||
let isQuitting = false;
|
||||
let isRestartingBackend = false;
|
||||
|
||||
log.initialize();
|
||||
|
||||
function getUserDataDir() {
|
||||
return join(app.getPath('userData'), 'data');
|
||||
}
|
||||
|
||||
function getLogsDir() {
|
||||
return join(app.getPath('userData'), 'logs');
|
||||
}
|
||||
|
||||
function ensureDesktopDirs() {
|
||||
mkdirSync(getUserDataDir(), { recursive: true });
|
||||
mkdirSync(getLogsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
function getServerEntryPath() {
|
||||
return join(app.getAppPath(), 'dist', 'server', 'index.js');
|
||||
}
|
||||
|
||||
function getTrayIconPath() {
|
||||
return join(app.getAppPath(), 'dist', 'web', 'favicon.png');
|
||||
}
|
||||
|
||||
function getWindowIconPath() {
|
||||
return getTrayIconPath();
|
||||
}
|
||||
|
||||
function resolveFrontendUrl() {
|
||||
const devUrl = (process.env.METAPI_DESKTOP_DEV_SERVER_URL || '').trim();
|
||||
if (devUrl) return devUrl;
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
function resolveExternalServerUrl() {
|
||||
return (process.env.METAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim();
|
||||
}
|
||||
|
||||
async function resolveServerPort() {
|
||||
const forcedPort = Number.parseInt(process.env.METAPI_DESKTOP_SERVER_PORT || '', 10);
|
||||
if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort;
|
||||
return getPort({ port: portNumbers(4310, 4399) });
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function buildTrayMenu() {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open Metapi',
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{
|
||||
label: 'Restart Backend',
|
||||
click: () => {
|
||||
void restartBackend();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Launch at Login',
|
||||
type: 'checkbox',
|
||||
checked: app.getLoginItemSettings().openAtLogin,
|
||||
click: (menuItem) => {
|
||||
app.setLoginItemSettings({ openAtLogin: menuItem.checked });
|
||||
tray?.setContextMenu(buildTrayMenu());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: () => {
|
||||
void checkForUpdates(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Logs Folder',
|
||||
click: () => {
|
||||
void shell.openPath(getLogsDir());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setupTray() {
|
||||
if (tray) return;
|
||||
const trayImage = nativeImage.createFromPath(getTrayIconPath());
|
||||
tray = new Tray(trayImage);
|
||||
tray.setToolTip('Metapi');
|
||||
tray.setContextMenu(buildTrayMenu());
|
||||
tray.on('double-click', () => showMainWindow());
|
||||
}
|
||||
|
||||
async function createMainWindow() {
|
||||
if (mainWindow) return mainWindow;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1480,
|
||||
height: 960,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
show: false,
|
||||
icon: existsSync(getWindowIconPath()) ? getWindowIconPath() : undefined,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
await mainWindow.loadURL(resolveFrontendUrl());
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function attachServerLogs(processHandle: ChildProcess) {
|
||||
processHandle.stdout?.on('data', (chunk) => {
|
||||
log.info(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
processHandle.stderr?.on('data', (chunk) => {
|
||||
log.error(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForManagedServerReady(url: string) {
|
||||
await waitForServerReady({
|
||||
url,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function startManagedBackend() {
|
||||
ensureDesktopDirs();
|
||||
const serverEntryPath = getServerEntryPath();
|
||||
const port = await resolveServerPort();
|
||||
serverUrl = createDesktopServerUrl(port);
|
||||
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: process.env,
|
||||
userDataDir: getUserDataDir(),
|
||||
logsDir: getLogsDir(),
|
||||
port,
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [serverEntryPath], {
|
||||
cwd: app.getAppPath(),
|
||||
env: {
|
||||
...env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: app.isPackaged ? 'production' : 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
serverProcess = child;
|
||||
|
||||
attachServerLogs(child);
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
const fatal = isFatalServerExit({ code, signal });
|
||||
serverProcess = null;
|
||||
if (!fatal || isQuitting || isRestartingBackend) return;
|
||||
void handleServerCrash(code);
|
||||
});
|
||||
|
||||
await waitForManagedServerReady(createDesktopHealthUrl(port));
|
||||
}
|
||||
|
||||
async function connectToExternalBackend() {
|
||||
const externalServerUrl = resolveExternalServerUrl();
|
||||
serverUrl = externalServerUrl;
|
||||
await waitForServerReady({
|
||||
url: `${externalServerUrl}/api/desktop/health`,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function stopManagedBackend() {
|
||||
if (!serverProcess) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const current = serverProcess;
|
||||
if (!current) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
}, 10_000);
|
||||
|
||||
current.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
current.kill();
|
||||
});
|
||||
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
async function handleServerCrash(code: number | null) {
|
||||
mainWindow?.hide();
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi backend stopped',
|
||||
message: `The local Metapi backend exited unexpectedly${typeof code === 'number' ? ` (code ${code})` : ''}.`,
|
||||
detail: 'You can restart the backend now or quit the desktop app.',
|
||||
buttons: ['Restart Backend', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await restartBackend();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
|
||||
async function restartBackend() {
|
||||
if (isRestartingBackend) return;
|
||||
isRestartingBackend = true;
|
||||
try {
|
||||
await stopManagedBackend();
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
if (mainWindow) {
|
||||
await mainWindow.loadURL(resolveFrontendUrl());
|
||||
showMainWindow();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to restart backend', error);
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Restart failed',
|
||||
message: 'Metapi could not restart the local backend.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
isRestartingBackend = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates(isManual: boolean) {
|
||||
if (!app.isPackaged) {
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Updates unavailable',
|
||||
message: 'Auto-update is only available for packaged desktop builds.',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
log.error('Failed to check for updates', error);
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Update check failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.logger = log;
|
||||
|
||||
autoUpdater.on('update-available', async (info) => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update available',
|
||||
message: `Metapi ${info.version} is available.`,
|
||||
detail: 'Download and install it after the current session?',
|
||||
buttons: ['Download', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await autoUpdater.downloadUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', async () => {
|
||||
log.info('No desktop updates available');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', async () => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update ready',
|
||||
message: 'The new Metapi desktop update is ready to install.',
|
||||
buttons: ['Install and Restart', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
isQuitting = true;
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
log.error('Auto-update error', error);
|
||||
});
|
||||
|
||||
void checkForUpdates(false);
|
||||
}
|
||||
|
||||
async function bootDesktopApp() {
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
|
||||
setupTray();
|
||||
await createMainWindow();
|
||||
setupAutoUpdater();
|
||||
app.setLoginItemSettings({ openAtLogin: app.getLoginItemSettings().openAtLogin });
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.whenReady()
|
||||
.then(async () => {
|
||||
try {
|
||||
await bootDesktopApp();
|
||||
} catch (error) {
|
||||
log.error('Failed to boot Metapi desktop', error);
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi failed to start',
|
||||
message: 'The desktop shell could not start the local Metapi service.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
buttons: ['Retry', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await bootDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error('Unhandled startup error', error);
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (!mainWindow) {
|
||||
void createMainWindow();
|
||||
return;
|
||||
}
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
tray?.destroy();
|
||||
tray = null;
|
||||
void stopManagedBackend();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep the tray resident app alive until the user explicitly quits.
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
|
||||
describe('desktop runtime helpers', () => {
|
||||
it('builds desktop server env with loopback host and app directories', () => {
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: {
|
||||
AUTH_TOKEN: 'admin-token',
|
||||
PROXY_TOKEN: 'proxy-token',
|
||||
},
|
||||
userDataDir: '/tmp/metapi-data',
|
||||
logsDir: '/tmp/metapi-logs',
|
||||
port: 4312,
|
||||
});
|
||||
|
||||
expect(env.HOST).toBe('127.0.0.1');
|
||||
expect(env.PORT).toBe('4312');
|
||||
expect(env.DATA_DIR).toBe('/tmp/metapi-data');
|
||||
expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs');
|
||||
expect(env.AUTH_TOKEN).toBe('admin-token');
|
||||
expect(env.PROXY_TOKEN).toBe('proxy-token');
|
||||
});
|
||||
|
||||
it('creates the browser URL from the local desktop port', () => {
|
||||
expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312');
|
||||
});
|
||||
|
||||
it('waits until the health probe returns ok', async () => {
|
||||
const fetcher = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 250,
|
||||
intervalMs: 1,
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('fails when the health probe never becomes ready', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 10,
|
||||
intervalMs: 1,
|
||||
})).rejects.toThrow('Timed out waiting for metapi desktop server');
|
||||
});
|
||||
|
||||
it('treats non-zero non-signal exits as fatal', () => {
|
||||
expect(isFatalServerExit({ code: 1, signal: null })).toBe(true);
|
||||
expect(isFatalServerExit({ code: 0, signal: null })).toBe(false);
|
||||
expect(isFatalServerExit({ code: null, signal: 'SIGTERM' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
type DesktopServerEnvInput = {
|
||||
inheritedEnv?: NodeJS.ProcessEnv;
|
||||
userDataDir: string;
|
||||
logsDir: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type WaitForServerReadyInput = {
|
||||
url: string;
|
||||
fetcher?: (input: string, init?: RequestInit) => Promise<{ ok: boolean }>;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
type ServerExitState = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_READY_INTERVAL_MS = 250;
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...(input.inheritedEnv || {}),
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(input.port),
|
||||
DATA_DIR: input.userDataDir,
|
||||
METAPI_DESKTOP: '1',
|
||||
METAPI_LOG_DIR: input.logsDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopServerUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export function createDesktopHealthUrl(port: number): string {
|
||||
return `${createDesktopServerUrl(port)}/api/desktop/health`;
|
||||
}
|
||||
|
||||
export async function waitForServerReady(input: WaitForServerReadyInput): Promise<void> {
|
||||
const fetcher = input.fetcher || ((url: string, init?: RequestInit) => fetch(url, init));
|
||||
const timeoutMs = input.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
const intervalMs = input.intervalMs ?? DEFAULT_READY_INTERVAL_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
try {
|
||||
const response = await fetcher(input.url, { method: 'GET' });
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Retry until timeout.
|
||||
}
|
||||
await delay(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for metapi desktop server');
|
||||
}
|
||||
|
||||
export function isFatalServerExit(exitState: ServerExitState): boolean {
|
||||
return exitState.code !== null && exitState.code !== 0 && !exitState.signal;
|
||||
}
|
||||
Reference in New Issue
Block a user