mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Scaffold v5 desktop shell
This commit is contained in:
parent
472de26bef
commit
b8bd5b26ec
33 changed files with 2612 additions and 21 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
44
desktop/README.md
Normal 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.
|
||||
35
desktop/electron.vite.config.ts
Normal file
35
desktop/electron.vite.config.ts
Normal 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
40
desktop/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
desktop/resources/icon.svg
Normal file
5
desktop/resources/icon.svg
Normal 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 |
63
desktop/src/main/__tests__/lifecycle.test.ts
Normal file
63
desktop/src/main/__tests__/lifecycle.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
44
desktop/src/main/__tests__/paths.test.ts
Normal file
44
desktop/src/main/__tests__/paths.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
24
desktop/src/main/__tests__/ports.test.ts
Normal file
24
desktop/src/main/__tests__/ports.test.ts
Normal 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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
7
desktop/src/main/app-metadata.ts
Normal file
7
desktop/src/main/app-metadata.ts
Normal 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,
|
||||
};
|
||||
40
desktop/src/main/bridge.ts
Normal file
40
desktop/src/main/bridge.ts
Normal 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
158
desktop/src/main/index.ts
Normal 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()));
|
||||
});
|
||||
108
desktop/src/main/lifecycle.ts
Normal file
108
desktop/src/main/lifecycle.ts
Normal 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
49
desktop/src/main/paths.ts
Normal 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
42
desktop/src/main/ports.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
127
desktop/src/main/process-supervisor.ts
Normal file
127
desktop/src/main/process-supervisor.ts
Normal 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
138
desktop/src/main/runtime.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
79
desktop/src/main/status-page.ts
Normal file
79
desktop/src/main/status-page.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { DesktopStatusSnapshot } from './types.js';
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
55
desktop/src/main/types.ts
Normal 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;
|
||||
}
|
||||
29
desktop/src/preload/index.ts
Normal file
29
desktop/src/preload/index.ts
Normal 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);
|
||||
43
desktop/src/renderer/index.html
Normal file
43
desktop/src/renderer/index.html
Normal 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
9
desktop/src/types/preload.d.ts
vendored
Normal 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
17
desktop/tsconfig.json
Normal 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
9
desktop/vitest.config.ts
Normal 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/**'],
|
||||
},
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
1375
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
packages:
|
||||
- "server"
|
||||
- "web"
|
||||
- "shared"
|
||||
- "cli"
|
||||
- "mcp"
|
||||
- 'server'
|
||||
- 'web'
|
||||
- 'shared'
|
||||
- 'cli'
|
||||
- 'mcp'
|
||||
- 'desktop'
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'>)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'>)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue