Scaffold v5 desktop shell

This commit is contained in:
Brad Groux 2026-05-31 15:04:12 -05:00
parent 472de26bef
commit b8bd5b26ec
33 changed files with 2612 additions and 21 deletions

3
.gitignore vendored
View file

@ -1,9 +1,11 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
out/
.next/
# IDE
@ -44,6 +46,7 @@ tasks/attachments/
tasks/archive-attachments/
.veritas-kanban/*
!.veritas-kanban/.gitkeep
.veritas-desktop-dev/
# Historical broken config data (should never have been tracked)
.veritas-kanban.broken/

44
desktop/README.md Normal file
View file

@ -0,0 +1,44 @@
# Veritas Kanban Desktop
This package is the v5 native desktop scaffold. It uses Electron with
electron-vite, starts the existing Veritas server as the local backend, and
loads the existing web UI.
## Development
```bash
pnpm desktop:dev
pnpm desktop:dev:fresh
```
`desktop:dev` launches a loopback-only local server and a Vite web renderer
without requiring a separate terminal. The desktop runtime chooses available
ports, writes logs under `.veritas-desktop-dev/<profile>/logs`, and uses SQLite
data under `.veritas-desktop-dev/<profile>/data`.
`desktop:dev:fresh` uses the `fresh` profile so onboarding and startup behavior
can be tested without reusing the default development home.
## Runtime Boundaries
- Electron main owns window lifecycle, process supervision, app paths, native
URL opening, status pages, and future native capabilities.
- Closing the last desktop window quits the app and stops supervised local
processes. Native menu/background behavior belongs in the dedicated menus
work.
- The renderer uses the existing Veritas web app and has no Node, filesystem,
process, or secret access.
- The preload bridge exposes only typed desktop operations:
`getAppInfo`, `getConnectionStatus`, `restartLocalServer`, `openExternal`,
and `onServerStatus`.
- Local development mode disables app auth only for the supervised loopback
runtime. The packaged app path keeps auth enabled and is expected to move to
keychain-backed bootstrap credentials in the dedicated keychain issue.
## Production Scaffold
`pnpm desktop:build` compiles the Electron main, preload, and fallback renderer.
Packaging, signing, notarization, updater metadata, and bundled server/web asset
layout are handled by later v5 desktop issues. Packaged mode expects a built
server entry at `server/dist/index.js` unless `VERITAS_DESKTOP_SERVER_ENTRY` is
provided.

View file

@ -0,0 +1,35 @@
import { resolve } from 'node:path';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
},
},
},
},
renderer: {
root: resolve(__dirname, 'src/renderer'),
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
},
},
},
},
});

40
desktop/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "@veritas-kanban/desktop",
"version": "4.3.2",
"private": true,
"description": "Veritas Kanban native desktop shell",
"author": "Brad Groux <brad@digitalmeld.io>",
"license": "MIT",
"type": "module",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:fresh": "VERITAS_DESKTOP_PROFILE=fresh electron-vite dev",
"build": "electron-vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint src electron.vite.config.ts --ext .ts",
"test": "vitest run --config vitest.config.ts",
"clean": "rm -rf dist out .veritas-desktop-dev"
},
"dependencies": {
"@veritas-kanban/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.7.0",
"electron": "^39.2.6",
"electron-vite": "^5.0.0",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vitest": "^4.1.6"
},
"build": {
"appId": "io.digitalmeld.veritas-kanban",
"productName": "Veritas Kanban",
"directories": {
"buildResources": "resources"
},
"mac": {
"category": "public.app-category.productivity"
}
}
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Veritas Kanban">
<rect width="512" height="512" rx="96" fill="#111318" />
<path d="M128 154h256v44H128zM128 234h174v44H128zM128 314h218v44H128z" fill="#eef1f7" />
<circle cx="382" cy="326" r="34" fill="#2f9e44" />
</svg>

After

Width:  |  Height:  |  Size: 312 B

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
buildServerEnvironment,
buildWebEnvironment,
createDesktopAdminKey,
createManagedProcessConfigs,
} from '../lifecycle.js';
import type { DesktopLifecycleOptions } from '../lifecycle.js';
function options(): DesktopLifecycleOptions {
return {
repoRoot: '/repo/veritas-kanban',
paths: {
appHome: '/tmp/veritas-desktop',
configDir: '/tmp/veritas-desktop/config',
dataDir: '/tmp/veritas-desktop/data',
logsDir: '/tmp/veritas-desktop/logs',
runtimeDir: '/tmp/veritas-desktop/runtime',
exportsDir: '/tmp/veritas-desktop/exports',
backupsDir: '/tmp/veritas-desktop/backups',
debugBundlesDir: '/tmp/veritas-desktop/debug-bundles',
},
serverPort: 39123,
webPort: 39124,
isPackaged: false,
};
}
describe('desktop lifecycle config', () => {
it('creates a dev-only admin key shape without fixed secrets', () => {
expect(createDesktopAdminKey('fresh profile')).toMatch(/^desktop-dev-admin-key-fresh-profile-/);
});
it('builds loopback server environment for local desktop dev mode', () => {
const env = buildServerEnvironment(options(), 'desktop-dev-admin-key-test-000000000000');
expect(env.HOST).toBe('127.0.0.1');
expect(env.PORT).toBe('39123');
expect(env.VERITAS_STORAGE).toBe('sqlite');
expect(env.VERITAS_DATA_DIR).toBe('/tmp/veritas-desktop/data');
expect(env.VERITAS_AUTH_ENABLED).toBe('false');
expect(env.CORS_ORIGINS).toContain('http://127.0.0.1:39124');
});
it('points web dev proxies at the selected server port', () => {
const env = buildWebEnvironment(options());
expect(env.VITE_API_PROXY_TARGET).toBe('http://127.0.0.1:39123');
expect(env.VITE_WS_PROXY_TARGET).toBe('ws://127.0.0.1:39123');
});
it('creates server and web process configs in dev mode', () => {
const configs = createManagedProcessConfigs(
options(),
'desktop-dev-admin-key-test-000000000000'
);
expect(configs.map((config) => config.name)).toEqual(['server', 'web']);
expect(configs[0]?.readyUrl).toBe('http://127.0.0.1:39123/api/health');
expect(configs[1]?.readyUrl).toBe('http://127.0.0.1:39124');
});
});

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { createDesktopPaths, resolveRepoRoot } from '../paths.js';
describe('desktop paths', () => {
it('uses a profile-isolated dev home in the repo', () => {
const paths = createDesktopPaths({
userDataPath: '/Users/example/Library/Application Support/Veritas Kanban',
repoRoot: '/repo/veritas-kanban',
isPackaged: false,
profile: 'fresh profile',
});
expect(paths.appHome).toBe(
path.join('/repo/veritas-kanban', '.veritas-desktop-dev', 'fresh-profile')
);
expect(paths.dataDir).toBe(path.join(paths.appHome, 'data'));
expect(paths.logsDir).toBe(path.join(paths.appHome, 'logs'));
expect(paths.runtimeDir).toBe(path.join(paths.appHome, 'runtime'));
});
it('uses app userData in packaged mode', () => {
const paths = createDesktopPaths({
userDataPath: '/Users/example/Library/Application Support/Veritas Kanban',
repoRoot: '/repo/veritas-kanban',
isPackaged: true,
});
expect(paths.appHome).toBe('/Users/example/Library/Application Support/Veritas Kanban');
});
it('resolves repo root from package cwd', () => {
expect(
resolveRepoRoot('/repo/veritas-kanban/desktop/out/main', '/repo/veritas-kanban/desktop')
).toBe('/repo/veritas-kanban');
});
it('resolves repo root from electron-vite app output path', () => {
expect(resolveRepoRoot('/repo/veritas-kanban/desktop/out/main', '/repo/veritas-kanban')).toBe(
'/repo/veritas-kanban'
);
});
});

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import net from 'node:net';
import { findAvailablePort, isPortAvailable } from '../ports.js';
describe('port selection', () => {
it('returns the preferred port when it is available', async () => {
const port = await findAvailablePort(47631, '127.0.0.1', 1);
expect(port).toBe(47631);
});
it('falls forward when the preferred port is busy', async () => {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(47632, '127.0.0.1', resolve));
try {
expect(await isPortAvailable(47632)).toBe(false);
const port = await findAvailablePort(47632, '127.0.0.1', 3);
expect(port).toBeGreaterThan(47632);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});

View file

@ -0,0 +1,7 @@
export const DESKTOP_APP_NAME = 'Veritas Kanban';
export const DESKTOP_APP_ID = 'io.digitalmeld.veritas-kanban';
export const DESKTOP_PROTOCOL = 'veritas-kanban';
export const DESKTOP_MIN_WINDOW = {
width: 1180,
height: 760,
};

View file

@ -0,0 +1,40 @@
import type { IpcMain, Shell } from 'electron';
import { DESKTOP_APP_ID, DESKTOP_APP_NAME } from './app-metadata.js';
import type { DesktopAppInfo } from './types.js';
import type { DesktopRuntime } from './runtime.js';
const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']);
export function registerDesktopBridge(
ipcMain: IpcMain,
runtime: DesktopRuntime,
shell: Shell,
packaged: boolean
): void {
ipcMain.handle(
'desktop:get-app-info',
(): DesktopAppInfo => ({
name: DESKTOP_APP_NAME,
appId: DESKTOP_APP_ID,
version: process.env.npm_package_version || '0.0.0',
platform: process.platform,
packaged,
})
);
ipcMain.handle('desktop:get-connection-status', () => runtime.snapshot());
ipcMain.handle('desktop:restart-local-server', () => runtime.restartLocalServer());
ipcMain.handle('desktop:open-external', async (_event, url: unknown) => {
if (typeof url !== 'string') {
throw new Error('URL must be a string');
}
const parsed = new URL(url);
if (!SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol)) {
throw new Error(`External URL protocol is not allowed: ${parsed.protocol}`);
}
await shell.openExternal(parsed.toString());
});
}

158
desktop/src/main/index.ts Normal file
View file

@ -0,0 +1,158 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import path from 'node:path';
import { mkdirSync } from 'node:fs';
import { DESKTOP_APP_ID, DESKTOP_APP_NAME, DESKTOP_MIN_WINDOW } from './app-metadata.js';
import { registerDesktopBridge } from './bridge.js';
import { createDesktopPaths, resolveRepoRoot } from './paths.js';
import { findAvailablePort } from './ports.js';
import { DesktopRuntime } from './runtime.js';
import { statusPageUrl } from './status-page.js';
let mainWindow: BrowserWindow | null = null;
let runtime: DesktopRuntime | null = null;
let quitting = false;
let shutdownStarted = false;
function isPackagedRuntime(): boolean {
return app.isPackaged || process.env.VERITAS_DESKTOP_PRODUCTION === 'true';
}
const launchPackaged = isPackagedRuntime();
const launchRepoRoot = resolveRepoRoot(app.getAppPath());
const launchProfile = process.env.VERITAS_DESKTOP_PROFILE || 'default';
if (!launchPackaged) {
const devUserDataPath = path.join(
launchRepoRoot,
'.veritas-desktop-dev',
launchProfile,
'app-home'
);
mkdirSync(devUserDataPath, { recursive: true });
app.setPath('userData', devUserDataPath);
}
function createMainWindow(): BrowserWindow {
const preloadPath = path.join(__dirname, '../preload/index.mjs');
const window = new BrowserWindow({
title: DESKTOP_APP_NAME,
minWidth: DESKTOP_MIN_WINDOW.width,
minHeight: DESKTOP_MIN_WINDOW.height,
width: 1360,
height: 900,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
backgroundColor: '#111318',
show: false,
webPreferences: {
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
window.once('ready-to-show', () => window.show());
window.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: 'deny' };
});
window.webContents.on('will-navigate', (event, url) => {
const current = runtime?.getRendererOrigin();
if (current && !url.startsWith(current)) {
event.preventDefault();
void shell.openExternal(url);
}
});
window.webContents.on('did-fail-load', (_event, _code, description) => {
if (!quitting) {
void window.loadURL(
statusPageUrl('Veritas Kanban could not load', description, runtime?.snapshot())
);
}
});
return window;
}
async function boot(): Promise<void> {
app.setName(DESKTOP_APP_NAME);
app.setAppUserModelId(DESKTOP_APP_ID);
const packaged = launchPackaged;
const repoRoot = launchRepoRoot;
const profile = launchProfile;
const paths = createDesktopPaths({
userDataPath: app.getPath('userData'),
repoRoot,
isPackaged: packaged,
profile,
});
const serverPort = await findAvailablePort(
Number(process.env.VERITAS_DESKTOP_SERVER_PORT || 3001)
);
const webPort = await findAvailablePort(Number(process.env.VERITAS_DESKTOP_WEB_PORT || 3000));
mainWindow = createMainWindow();
await mainWindow.loadURL(statusPageUrl('Starting Veritas Kanban', 'Preparing the local app.'));
runtime = new DesktopRuntime({
repoRoot,
paths,
serverPort,
webPort,
isPackaged: packaged,
profile,
});
registerDesktopBridge(ipcMain, runtime, shell, packaged);
runtime.on('status', (status) => {
mainWindow?.webContents.send('desktop:server-status', status);
});
try {
await runtime.start();
await mainWindow.loadURL(runtime.getRendererOrigin());
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await mainWindow.loadURL(
statusPageUrl('Veritas Kanban startup failed', message, runtime.snapshot())
);
}
}
app.on('ready', () => {
void boot();
});
app.on('before-quit', (event) => {
quitting = true;
if (runtime && !shutdownStarted) {
event.preventDefault();
shutdownStarted = true;
void runtime.stop().finally(() => app.quit());
}
});
app.on('window-all-closed', () => {
app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
void boot();
}
});
process.on('uncaughtException', (error) => {
mainWindow?.loadURL(
statusPageUrl('Veritas Kanban desktop error', error.message, runtime?.snapshot())
);
});
process.on('unhandledRejection', (reason) => {
const message = reason instanceof Error ? reason.message : String(reason);
mainWindow?.loadURL(statusPageUrl('Veritas Kanban desktop error', message, runtime?.snapshot()));
});

View file

@ -0,0 +1,108 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { DesktopPaths, ManagedProcessConfig } from './types.js';
const ADMIN_KEY_PREFIX = 'desktop-dev-admin-key';
export interface DesktopLifecycleOptions {
repoRoot: string;
paths: DesktopPaths;
serverPort: number;
webPort: number;
isPackaged: boolean;
}
function pnpmCommand(): string {
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
}
export function createDesktopAdminKey(profile = 'default'): string {
const safeProfile = profile.replace(/[^a-zA-Z0-9._-]/g, '-');
return `${ADMIN_KEY_PREFIX}-${safeProfile}-${randomUUID()}`;
}
export function buildServerEnvironment(
options: DesktopLifecycleOptions,
adminKey: string
): NodeJS.ProcessEnv {
const serverOrigin = `http://127.0.0.1:${options.serverPort}`;
const webOrigin = `http://127.0.0.1:${options.webPort}`;
return {
...process.env,
NODE_ENV: options.isPackaged ? 'production' : 'development',
HOST: '127.0.0.1',
PORT: String(options.serverPort),
VERITAS_ADMIN_KEY: adminKey,
VERITAS_AUTH_ENABLED: options.isPackaged ? 'true' : 'false',
VERITAS_AUTH_LOCALHOST_BYPASS: 'false',
VERITAS_STORAGE: 'sqlite',
VERITAS_DATA_DIR: options.paths.dataDir,
VERITAS_DISABLE_WATCHERS: '1',
CORS_ORIGINS: `${serverOrigin},${webOrigin},http://localhost:${options.webPort}`,
};
}
export function buildWebEnvironment(options: DesktopLifecycleOptions): NodeJS.ProcessEnv {
const serverOrigin = `http://127.0.0.1:${options.serverPort}`;
return {
...process.env,
VITE_HOST: '127.0.0.1',
VITE_API_PROXY_TARGET: serverOrigin,
VITE_WS_PROXY_TARGET: serverOrigin.replace(/^http/, 'ws'),
};
}
export function createManagedProcessConfigs(
options: DesktopLifecycleOptions,
adminKey: string
): ManagedProcessConfig[] {
const serverConfig: ManagedProcessConfig = options.isPackaged
? {
name: 'server',
command: process.execPath,
args: [
process.env.VERITAS_DESKTOP_SERVER_ENTRY ||
path.join(options.repoRoot, 'server/dist/index.js'),
],
cwd: options.repoRoot,
env: buildServerEnvironment(options, adminKey),
logFile: path.join(options.paths.logsDir, 'server.log'),
readyUrl: `http://127.0.0.1:${options.serverPort}/api/health`,
}
: {
name: 'server',
command: pnpmCommand(),
args: ['--filter', '@veritas-kanban/server', 'dev'],
cwd: options.repoRoot,
env: buildServerEnvironment(options, adminKey),
logFile: path.join(options.paths.logsDir, 'server.log'),
readyUrl: `http://127.0.0.1:${options.serverPort}/api/health`,
};
if (options.isPackaged) {
return [serverConfig];
}
const webConfig: ManagedProcessConfig = {
name: 'web',
command: pnpmCommand(),
args: [
'--filter',
'@veritas-kanban/web',
'dev',
'--host',
'127.0.0.1',
'--port',
String(options.webPort),
],
cwd: options.repoRoot,
env: buildWebEnvironment(options),
logFile: path.join(options.paths.logsDir, 'web.log'),
readyUrl: `http://127.0.0.1:${options.webPort}`,
};
return [serverConfig, webConfig];
}

49
desktop/src/main/paths.ts Normal file
View file

@ -0,0 +1,49 @@
import path from 'node:path';
import type { DesktopPaths } from './types.js';
export interface CreateDesktopPathsOptions {
userDataPath: string;
repoRoot: string;
isPackaged: boolean;
profile?: string;
}
function profileSegment(profile: string | undefined): string {
return (profile || 'default').replace(/[^a-zA-Z0-9._-]/g, '-');
}
export function createDesktopPaths(options: CreateDesktopPathsOptions): DesktopPaths {
const appHome = options.isPackaged
? options.userDataPath
: path.join(options.repoRoot, '.veritas-desktop-dev', profileSegment(options.profile));
return {
appHome,
configDir: path.join(appHome, 'config'),
dataDir: path.join(appHome, 'data'),
logsDir: path.join(appHome, 'logs'),
runtimeDir: path.join(appHome, 'runtime'),
exportsDir: path.join(appHome, 'exports'),
backupsDir: path.join(appHome, 'backups'),
debugBundlesDir: path.join(appHome, 'debug-bundles'),
};
}
export function resolveRepoRoot(appPath: string, cwd = process.cwd()): string {
if (process.env.VERITAS_REPO_ROOT) {
return process.env.VERITAS_REPO_ROOT;
}
if (path.basename(cwd) === 'desktop') {
return path.resolve(cwd, '..');
}
const segments = appPath.split(path.sep);
const desktopIndex = segments.lastIndexOf('desktop');
if (desktopIndex > 0) {
return segments.slice(0, desktopIndex).join(path.sep) || path.sep;
}
return path.resolve(appPath, '..');
}

42
desktop/src/main/ports.ts Normal file
View file

@ -0,0 +1,42 @@
import net from 'node:net';
export async function isPortAvailable(port: number, host = '127.0.0.1'): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, host);
});
}
export async function findAvailablePort(
preferredPort: number,
host = '127.0.0.1',
maxAttempts = 50
): Promise<number> {
for (let offset = 0; offset < maxAttempts; offset += 1) {
const candidate = preferredPort + offset;
if (await isPortAvailable(candidate, host)) {
return candidate;
}
}
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
server.close(() => {
if (typeof address === 'object' && address) {
resolve(address.port);
return;
}
reject(new Error('Unable to allocate an ephemeral port'));
});
});
server.listen(0, host);
});
}

View file

@ -0,0 +1,127 @@
import { EventEmitter } from 'node:events';
import { createWriteStream, type WriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { spawn, type ChildProcess } from 'node:child_process';
import type { ManagedProcessConfig, ManagedProcessSnapshot, DesktopProcessState } from './types.js';
export class ProcessSupervisor extends EventEmitter {
private child: ChildProcess | null = null;
private state: DesktopProcessState = 'idle';
private lastError: string | null = null;
private startedAt: string | null = null;
private exitedAt: string | null = null;
private logStream: WriteStream | null = null;
private stopping = false;
constructor(private readonly config: ManagedProcessConfig) {
super();
}
snapshot(): ManagedProcessSnapshot {
return {
name: this.config.name,
state: this.state,
pid: this.child?.pid ?? null,
port: this.config.readyUrl ? Number(new URL(this.config.readyUrl).port) : null,
lastError: this.lastError,
startedAt: this.startedAt,
exitedAt: this.exitedAt,
};
}
async start(): Promise<void> {
if (this.child) return;
await mkdir(path.dirname(this.config.logFile), { recursive: true });
this.logStream = createWriteStream(this.config.logFile, { flags: 'a' });
this.setState('starting');
this.stopping = false;
this.startedAt = new Date().toISOString();
this.exitedAt = null;
this.lastError = null;
const child = spawn(this.config.command, this.config.args, {
cwd: this.config.cwd,
env: this.config.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
this.child = child;
this.log(`[desktop] started ${this.config.command} ${this.config.args.join(' ')}\n`);
child.stdout?.on('data', (chunk: Buffer) => this.log(chunk));
child.stderr?.on('data', (chunk: Buffer) => this.log(chunk));
child.once('error', (error) => {
this.lastError = error.message;
this.setState('failed');
this.emit('error', error);
});
child.once('exit', (code, signal) => {
this.exitedAt = new Date().toISOString();
this.log(`[desktop] exited code=${code ?? 'null'} signal=${signal ?? 'null'}\n`);
this.child = null;
this.closeLogStream();
if (this.stopping || code === 0) {
this.setState('stopped');
return;
}
this.lastError = `${this.config.name} exited unexpectedly with code ${code ?? 'null'} signal ${signal ?? 'null'}`;
this.setState('failed');
});
}
markReady(): void {
if (this.child) {
this.setState('ready');
}
}
async stop(): Promise<void> {
if (!this.child) {
this.setState('stopped');
return;
}
this.stopping = true;
this.setState('stopping');
const child = this.child;
const timeoutMs = this.config.shutdownTimeoutMs ?? 5000;
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, timeoutMs);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
child.kill('SIGTERM');
});
}
async restart(): Promise<void> {
await this.stop();
await this.start();
}
private setState(state: DesktopProcessState): void {
this.state = state;
this.emit('state', this.snapshot());
}
private log(chunk: Buffer | string): void {
this.logStream?.write(chunk);
}
private closeLogStream(): void {
this.logStream?.end();
this.logStream = null;
}
}

138
desktop/src/main/runtime.ts Normal file
View file

@ -0,0 +1,138 @@
import { EventEmitter } from 'node:events';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { createDesktopAdminKey, createManagedProcessConfigs } from './lifecycle.js';
import { ProcessSupervisor } from './process-supervisor.js';
import type { DesktopPaths, DesktopStatusSnapshot } from './types.js';
export interface DesktopRuntimeOptions {
repoRoot: string;
paths: DesktopPaths;
serverPort: number;
webPort: number;
isPackaged: boolean;
profile: string;
}
export class DesktopRuntime extends EventEmitter {
private readonly server: ProcessSupervisor;
private readonly web: ProcessSupervisor | null;
private lastError: string | null = null;
private readonly serverOrigin: string;
private readonly rendererOrigin: string;
constructor(private readonly options: DesktopRuntimeOptions) {
super();
const adminKey = createDesktopAdminKey(options.profile);
const [serverConfig, webConfig] = createManagedProcessConfigs(options, adminKey);
this.server = new ProcessSupervisor(serverConfig);
this.web = webConfig ? new ProcessSupervisor(webConfig) : null;
this.serverOrigin = `http://127.0.0.1:${options.serverPort}`;
this.rendererOrigin = options.isPackaged
? this.serverOrigin
: `http://127.0.0.1:${options.webPort}`;
for (const supervisor of [this.server, this.web].filter(Boolean) as ProcessSupervisor[]) {
supervisor.on('state', () => this.emitStatus());
supervisor.on('error', (error) => {
this.lastError = error instanceof Error ? error.message : String(error);
this.emitStatus();
});
}
}
getRendererOrigin(): string {
return this.rendererOrigin;
}
snapshot(): DesktopStatusSnapshot {
return {
mode: this.options.isPackaged ? 'local-production' : 'local-dev',
server: this.server.snapshot(),
web: this.web?.snapshot(),
serverOrigin: this.serverOrigin,
rendererOrigin: this.rendererOrigin,
appHome: this.options.paths.appHome,
lastError: this.lastError,
};
}
async start(): Promise<void> {
await this.ensureDirectories();
await this.writeRuntimeState();
await this.server.start();
await this.waitForReady(this.serverOrigin + '/api/health', 'server');
this.server.markReady();
if (this.web) {
await this.web.start();
await this.waitForReady(this.rendererOrigin, 'web');
this.web.markReady();
}
this.emitStatus();
}
async restartLocalServer(): Promise<DesktopStatusSnapshot> {
await this.server.restart();
await this.waitForReady(this.serverOrigin + '/api/health', 'server');
this.server.markReady();
this.emitStatus();
return this.snapshot();
}
async stop(): Promise<void> {
await Promise.all([this.web?.stop(), this.server.stop()].filter(Boolean) as Promise<void>[]);
await rm(path.join(this.options.paths.runtimeDir, 'server-state.json'), { force: true });
}
private async ensureDirectories(): Promise<void> {
await Promise.all(
Object.values(this.options.paths).map((targetPath) => mkdir(targetPath, { recursive: true }))
);
}
private async writeRuntimeState(): Promise<void> {
await mkdir(this.options.paths.runtimeDir, { recursive: true });
await writeFile(
path.join(this.options.paths.runtimeDir, 'server-state.json'),
JSON.stringify(
{
mode: this.options.isPackaged ? 'local-production' : 'local-dev',
serverOrigin: this.serverOrigin,
rendererOrigin: this.rendererOrigin,
updatedAt: new Date().toISOString(),
},
null,
2
)
);
}
private async waitForReady(url: string, label: string): Promise<void> {
const deadline = Date.now() + 45_000;
let lastError: string | null = null;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
lastError = `${label} returned ${response.status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
this.lastError = `${label} did not become ready: ${lastError ?? 'timeout'}`;
this.emitStatus();
throw new Error(this.lastError);
}
private emitStatus(): void {
this.emit('status', this.snapshot());
}
}

View file

@ -0,0 +1,79 @@
import type { DesktopStatusSnapshot } from './types.js';
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function statusPage(title: string, message: string, status?: DesktopStatusSnapshot): string {
const statusJson = status ? escapeHtml(JSON.stringify(status, null, 2)) : '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<style>
:root {
color-scheme: dark;
font-family: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111318;
color: #eef1f7;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
}
main {
width: min(720px, calc(100vw - 48px));
}
h1 {
margin: 0 0 12px;
font-size: 28px;
font-weight: 650;
}
p {
margin: 0;
color: #b8c0cf;
line-height: 1.5;
}
pre {
margin-top: 24px;
max-height: 320px;
overflow: auto;
border: 1px solid #2b3242;
background: #171b24;
border-radius: 8px;
padding: 16px;
color: #d8deea;
font-size: 12px;
}
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${statusJson ? `<pre>${statusJson}</pre>` : ''}
</main>
</body>
</html>`;
}
export function statusPageUrl(
title: string,
message: string,
status?: DesktopStatusSnapshot
): string {
return `data:text/html;charset=utf-8,${encodeURIComponent(statusPage(title, message, status))}`;
}

55
desktop/src/main/types.ts Normal file
View file

@ -0,0 +1,55 @@
export type DesktopProcessName = 'server' | 'web';
export type DesktopProcessState = 'idle' | 'starting' | 'ready' | 'stopping' | 'stopped' | 'failed';
export type DesktopConnectionMode = 'local-dev' | 'local-production';
export interface DesktopPaths {
appHome: string;
configDir: string;
dataDir: string;
logsDir: string;
runtimeDir: string;
exportsDir: string;
backupsDir: string;
debugBundlesDir: string;
}
export interface ManagedProcessConfig {
name: DesktopProcessName;
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
logFile: string;
readyUrl?: string;
shutdownTimeoutMs?: number;
}
export interface ManagedProcessSnapshot {
name: DesktopProcessName;
state: DesktopProcessState;
pid: number | null;
port: number | null;
lastError: string | null;
startedAt: string | null;
exitedAt: string | null;
}
export interface DesktopStatusSnapshot {
mode: DesktopConnectionMode;
server: ManagedProcessSnapshot;
web?: ManagedProcessSnapshot;
serverOrigin: string | null;
rendererOrigin: string | null;
appHome: string;
lastError: string | null;
}
export interface DesktopAppInfo {
name: string;
appId: string;
version: string;
platform: NodeJS.Platform;
packaged: boolean;
}

View file

@ -0,0 +1,29 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { DesktopAppInfo, DesktopStatusSnapshot } from '../main/types.js';
export interface VeritasDesktopApi {
getAppInfo(): Promise<DesktopAppInfo>;
getConnectionStatus(): Promise<DesktopStatusSnapshot>;
restartLocalServer(): Promise<DesktopStatusSnapshot>;
openExternal(url: string): Promise<void>;
onServerStatus(listener: (status: DesktopStatusSnapshot) => void): () => void;
}
const api: VeritasDesktopApi = {
getAppInfo: () => ipcRenderer.invoke('desktop:get-app-info') as Promise<DesktopAppInfo>,
getConnectionStatus: () =>
ipcRenderer.invoke('desktop:get-connection-status') as Promise<DesktopStatusSnapshot>,
restartLocalServer: () =>
ipcRenderer.invoke('desktop:restart-local-server') as Promise<DesktopStatusSnapshot>,
openExternal: (url: string) => ipcRenderer.invoke('desktop:open-external', url) as Promise<void>,
onServerStatus: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, status: DesktopStatusSnapshot): void => {
listener(status);
};
ipcRenderer.on('desktop:server-status', handler);
return () => ipcRenderer.off('desktop:server-status', handler);
},
};
contextBridge.exposeInMainWorld('veritasDesktop', api);

View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Veritas Kanban</title>
<style>
:root {
color-scheme: dark;
font-family: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111318;
color: #eef1f7;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
}
main {
width: min(680px, calc(100vw - 48px));
}
h1 {
margin: 0 0 12px;
font-size: 28px;
}
p {
margin: 0;
color: #b8c0cf;
}
</style>
</head>
<body>
<main>
<h1>Starting Veritas Kanban</h1>
<p>Preparing the local desktop runtime.</p>
</main>
</body>
</html>

9
desktop/src/types/preload.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
import type { VeritasDesktopApi } from '../preload/index.js';
declare global {
interface Window {
veritasDesktop?: VeritasDesktopApi;
}
}
export {};

17
desktop/tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node", "vitest"],
"noEmit": true
},
"include": ["src/**/*.ts", "electron.vite.config.ts"],
"exclude": ["dist", "out", "node_modules"]
}

9
desktop/vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
exclude: ['dist/**', 'out/**', 'node_modules/**'],
},
});

View file

@ -9,9 +9,11 @@ export default [
{
ignores: [
'**/dist/**',
'**/out/**',
'**/node_modules/**',
'**/*.d.ts',
'.veritas-kanban/**',
'.veritas-desktop-dev/**',
// Config files (CommonJS tooling)
'web/tailwind.config.js',
'web/vite.config.js',
@ -22,9 +24,17 @@ export default [
// Base JS config
js.configs.recommended,
// TypeScript files (server, shared, cli, mcp)
// TypeScript files (server, shared, cli, mcp, desktop)
{
files: ['server/src/**/*.ts', 'shared/src/**/*.ts', 'cli/src/**/*.ts', 'mcp/src/**/*.ts'],
files: [
'server/src/**/*.ts',
'shared/src/**/*.ts',
'cli/src/**/*.ts',
'mcp/src/**/*.ts',
'desktop/src/**/*.ts',
'desktop/electron.vite.config.ts',
'desktop/vitest.config.ts',
],
languageOptions: {
parser: tsparser,
parserOptions: {

View file

@ -13,9 +13,13 @@
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "concurrently -n server,web -c blue,green \"pnpm --filter server dev\" \"pnpm --filter web dev\"",
"desktop:dev": "pnpm --filter @veritas-kanban/desktop dev",
"desktop:dev:fresh": "pnpm --filter @veritas-kanban/desktop dev:fresh",
"desktop:build": "pnpm --filter @veritas-kanban/desktop build",
"desktop:test": "pnpm --filter @veritas-kanban/desktop test",
"dev:clean": "bash scripts/dev-clean.sh",
"dev:watchdog": "bash scripts/dev-watchdog.sh",
"build": "pnpm --filter @veritas-kanban/shared build && pnpm --filter @veritas-kanban/server build && pnpm --filter @veritas-kanban/web build && pnpm --filter @veritas-kanban/cli build && pnpm --filter @veritas-kanban/mcp build",
"build": "pnpm --filter @veritas-kanban/shared build && pnpm --filter @veritas-kanban/server build && pnpm --filter @veritas-kanban/web build && pnpm --filter @veritas-kanban/cli build && pnpm --filter @veritas-kanban/mcp build && pnpm --filter @veritas-kanban/desktop build",
"lint": "eslint .",
"lint:budget": "eslint . --max-warnings=714",
"lint:fix": "eslint . --fix",

1375
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
packages:
- "server"
- "web"
- "shared"
- "cli"
- "mcp"
- 'server'
- 'web'
- 'shared'
- 'cli'
- 'mcp'
- 'desktop'

View file

@ -49,6 +49,9 @@ export const envSchema = z.object({
/** HTTP port the server listens on */
PORT: portSchema.default(3001),
/** Optional HTTP host/bind address */
HOST: z.string().optional(),
/** Node environment: development | production | test */
NODE_ENV: z.enum(['development', 'production', 'test']).optional().default('development'),

View file

@ -133,6 +133,7 @@ if (trustProxy !== undefined && trustProxy !== '') {
}
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST?.trim() || undefined;
// ============================================
// Performance: ETag Generation
@ -1052,7 +1053,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Start server
server.listen(PORT, () => {
server.listen(Number(PORT), HOST, () => {
const authStatus = getAuthStatus();
const localhostInfo = authStatus.localhostBypass
? `, localhost bypass [${authStatus.localhostRole}]`
@ -1065,8 +1066,9 @@ server.listen(PORT, () => {
log.info(
{
port: PORT,
api: `http://localhost:${PORT}`,
ws: `ws://localhost:${PORT}/ws`,
host: HOST || 'default',
api: `http://${HOST || 'localhost'}:${PORT}`,
ws: `ws://${HOST || 'localhost'}:${PORT}/ws`,
auth: authLine,
cors: corsLine,
helmet: true,

View file

@ -256,6 +256,28 @@ describe('Mantine-backed shared UI primitives', () => {
expect(screen.getByTestId('scroll-area').getAttribute('data-slot')).toBe('scroll-area');
});
it('applies legacy text field classes to the native input slot', () => {
renderWithProviders(
<div>
<Label htmlFor="password">Password</Label>
<Input id="password" className="pr-10" placeholder="Password" type="password" />
<Textarea aria-label="Notes" className="min-h-24" placeholder="Notes" />
</div>
);
const input = screen.getByLabelText('Password');
const inputWrapper = input.parentElement;
const textarea = screen.getByLabelText('Notes');
expect(input.getAttribute('data-slot')).toBe('input');
expect(input.className).toContain('border-input');
expect(input.className).toContain('pr-10');
expect(inputWrapper?.className).not.toContain('border-input');
expect(inputWrapper?.className).not.toContain('pr-10');
expect(textarea.className).toContain('border-input');
expect(textarea.className).toContain('min-h-24');
});
it('preserves checkbox and switch onCheckedChange compatibility', () => {
renderWithProviders(<ToggleProbe />);

View file

@ -3,15 +3,18 @@ import { Input as MantineInput, type InputProps as MantineInputProps } from '@ma
import { cn } from '@/lib/utils';
const inputClassName =
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<MantineInput
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
classNames={{
wrapper: 'w-full',
input: cn(inputClassName, className),
}}
{...(props as MantineInputProps & React.ComponentProps<'input'>)}
/>
);

View file

@ -6,15 +6,16 @@ import {
import { cn } from '@/lib/utils';
const textareaClassName =
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<MantineTextarea
data-slot="textarea"
classNames={{
input: cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
),
wrapper: 'w-full',
input: cn(textareaClassName, className),
}}
{...(props as MantineTextareaProps & React.ComponentProps<'textarea'>)}
/>

View file

@ -13,6 +13,8 @@ const viteAllowedHosts =
.map((host) => host.trim())
.filter(Boolean)
: undefined;
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://localhost:3001';
const wsProxyTarget = process.env.VITE_WS_PROXY_TARGET || apiProxyTarget.replace(/^http/, 'ws');
export default defineConfig({
// Support deployment under a sub-path (e.g., VITE_BASE_PATH=/kanban/)
@ -72,11 +74,11 @@ export default defineConfig({
allowedHosts: viteAllowedHosts,
proxy: {
'/api': {
target: 'http://localhost:3001',
target: apiProxyTarget,
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
target: wsProxyTarget,
ws: true,
},
},