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:
cita
2026-03-06 16:24:41 +08:00
parent acdc846b13
commit 2a810facb8
45 changed files with 6712 additions and 405 deletions
+451
View File
@@ -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.
});
+65
View File
@@ -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);
});
});
+67
View File
@@ -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;
}