Add macOS desktop release pipeline (#473)

This commit is contained in:
Brad Groux 2026-05-31 17:48:50 -05:00 committed by GitHub
parent 0815e97042
commit f0dceac22f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2634 additions and 113 deletions

66
.github/workflows/desktop-artifacts.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: Desktop Artifacts
on:
pull_request:
branches: [main]
paths:
- 'desktop/**'
- 'server/**'
- 'web/**'
- 'shared/**'
- 'docs/DESKTOP-RELEASE.md'
- 'scripts/desktop-after-pack.mjs'
- 'scripts/prepare-desktop-release.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/desktop-artifacts.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '22'
jobs:
mac-unsigned:
name: Unsigned macOS Artifact
runs-on: macos-15
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build workspace
run: pnpm build
- name: Prepare desktop runtime payload
run: pnpm --filter @veritas-kanban/desktop package:prepare
- name: Build unsigned macOS DMG and update metadata
working-directory: desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: pnpm exec electron-builder --mac dmg zip --publish never --config.mac.identity=null --config.mac.notarize=false
- name: Upload desktop artifacts
uses: actions/upload-artifact@v5
with:
name: veritas-kanban-mac-unsigned
path: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.yml
desktop/release/*.blockmap
if-no-files-found: error
retention-days: 14

78
.github/workflows/desktop-release.yml vendored Normal file
View file

@ -0,0 +1,78 @@
name: Desktop Release
on:
workflow_dispatch:
inputs:
channel:
description: Update channel to publish.
required: true
default: stable
type: choice
options:
- stable
- beta
- dev
release:
types: [published]
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: '22'
VERITAS_UPDATE_CHANNEL: ${{ github.event.inputs.channel || 'stable' }}
jobs:
mac-signed:
name: Signed and Notarized macOS Artifact
runs-on: macos-15
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Verify signing secrets are configured
env:
CSC_LINK: ${{ secrets.MACOS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
missing=0
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "::error::$name is required for signed/notarized desktop releases"
missing=1
fi
done
exit "$missing"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build workspace
run: pnpm build
- name: Prepare desktop runtime payload
run: pnpm --filter @veritas-kanban/desktop package:prepare
- name: Build, sign, notarize, and publish macOS artifacts
working-directory: desktop
env:
GH_TOKEN: ${{ github.token }}
CSC_LINK: ${{ secrets.MACOS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: pnpm exec electron-builder --mac dmg zip --publish always

2
.gitignore vendored
View file

@ -47,6 +47,8 @@ tasks/archive-attachments/
.veritas-kanban/*
!.veritas-kanban/.gitkeep
.veritas-desktop-dev/
desktop/.desktop-release/
desktop/release/
# Historical broken config data (should never have been tracked)
.veritas-kanban.broken/

View file

@ -78,6 +78,15 @@ with generic copy while preserving the durable target for click-through.
Window size, position, and maximized state are persisted per profile/workspace
in `config/window-state.json`.
## Release Packaging
Unsigned PR artifacts, signed/notarized release artifacts, update metadata, and
macOS smoke steps are documented in
[`docs/DESKTOP-RELEASE.md`](../docs/DESKTOP-RELEASE.md). Use the root
`desktop:package:mac:unsigned` script for local unsigned DMG/ZIP validation and
`desktop:release:mac` only when Apple signing/notarization credentials are
configured.
## Production Scaffold
`pnpm desktop:build` compiles the Electron main, preload, and fallback renderer.

View file

@ -11,17 +11,23 @@
"dev": "electron-vite dev",
"dev:fresh": "VERITAS_DESKTOP_PROFILE=fresh electron-vite dev",
"build": "electron-vite build",
"package:prepare": "node ../scripts/prepare-desktop-release.mjs",
"package:mac:dir": "pnpm package:prepare && electron-builder --mac dir --publish never --config.mac.identity=null --config.mac.notarize=false",
"package:mac:unsigned": "pnpm package:prepare && electron-builder --mac dmg zip --publish never --config.mac.identity=null --config.mac.notarize=false",
"release:mac": "pnpm package:prepare && electron-builder --mac dmg zip --publish always",
"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:*"
"@veritas-kanban/shared": "workspace:*",
"electron-updater": "^6.8.3"
},
"devDependencies": {
"@types/node": "^25.7.0",
"electron": "^39.2.6",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"typescript": "^6.0.3",
"vite": "^7.2.7",
@ -30,11 +36,73 @@
"build": {
"appId": "io.digitalmeld.veritas-kanban",
"productName": "Veritas Kanban",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"asar": true,
"afterPack": "../scripts/desktop-after-pack.mjs",
"directories": {
"buildResources": "resources"
"buildResources": "resources",
"output": "release"
},
"files": [
"out/**/*",
"resources/**/*",
"package.json",
"node_modules/**/*"
],
"extraResources": [
{
"from": ".desktop-release/server",
"to": "server"
},
{
"from": ".desktop-release/web",
"to": "web"
}
],
"protocols": [
{
"name": "Veritas Kanban",
"schemes": [
"veritas"
],
"role": "Viewer"
}
],
"generateUpdatesFilesForAllChannels": true,
"mac": {
"category": "public.app-category.productivity"
}
"category": "public.app-category.productivity",
"target": [
"dmg",
"zip"
],
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "resources/entitlements.mac.plist",
"entitlementsInherit": "resources/entitlements.mac.inherit.plist",
"notarize": true
},
"dmg": {
"sign": false,
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"publish": [
{
"provider": "github",
"owner": "BradGroux",
"repo": "veritas-kanban",
"releaseType": "draft"
}
]
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View file

@ -8,7 +8,10 @@ import {
} from '../commands.js';
import type { DesktopRuntime } from '../runtime.js';
import type { DesktopStatusSnapshot } from '../types.js';
import { DESKTOP_COMMAND_NAMES } from '../../shared/desktop-bridge-contracts.js';
import {
DESKTOP_COMMAND_NAMES,
type DesktopUpdateStatus,
} from '../../shared/desktop-bridge-contracts.js';
function status(): DesktopStatusSnapshot {
return {
@ -37,6 +40,15 @@ function status(): DesktopStatusSnapshot {
};
}
function updateStatus(state: DesktopUpdateStatus['state'] = 'idle'): DesktopUpdateStatus {
return {
state,
currentVersion: '4.3.2',
channel: 'stable',
checkedAt: '2026-05-31T00:00:00.000Z',
};
}
function dispatcher() {
const runtime = {
snapshot: vi.fn(status),
@ -46,7 +58,9 @@ function dispatcher() {
openPath: vi.fn(async () => ''),
} as unknown as Shell;
const sendRendererCommand = vi.fn();
const sendUpdateStatus = vi.fn();
const checkForUpdates = vi.fn(async () => updateStatus('idle'));
const downloadUpdate = vi.fn(async () => updateStatus('ready'));
const installUpdate = vi.fn(() => updateStatus('ready'));
const showTestNotification = vi.fn();
const copyRedactedDiagnostics = vi.fn();
@ -54,7 +68,9 @@ function dispatcher() {
runtime,
shell,
sendRendererCommand,
sendUpdateStatus,
checkForUpdates,
downloadUpdate,
installUpdate,
showTestNotification,
copyRedactedDiagnostics,
dispatcher: new DesktopCommandDispatcher({
@ -62,7 +78,9 @@ function dispatcher() {
shell,
quit: vi.fn(),
sendRendererCommand,
sendUpdateStatus,
checkForUpdates,
downloadUpdate,
installUpdate,
showTestNotification,
copyRedactedDiagnostics,
}),
@ -107,6 +125,8 @@ describe('desktop command registry', () => {
});
await harness.dispatcher.dispatch(createDesktopCommandRequest('open-logs', 'menu'));
await harness.dispatcher.dispatch(createDesktopCommandRequest('check-for-updates', 'menu'));
await harness.dispatcher.dispatch(createDesktopCommandRequest('download-update', 'menu'));
await harness.dispatcher.dispatch(createDesktopCommandRequest('install-update', 'menu'));
await harness.dispatcher.dispatch(createDesktopCommandRequest('test-notification', 'menu'));
await harness.dispatcher.dispatch(
createDesktopCommandRequest('copy-redacted-diagnostics', 'menu')
@ -114,11 +134,9 @@ describe('desktop command registry', () => {
expect(harness.runtime.restartLocalServer).toHaveBeenCalledTimes(1);
expect(harness.shell.openPath).toHaveBeenCalledWith('/tmp/veritas/logs');
expect(harness.sendUpdateStatus).toHaveBeenCalledWith(
expect.objectContaining({
state: 'unsupported',
})
);
expect(harness.checkForUpdates).toHaveBeenCalledTimes(1);
expect(harness.downloadUpdate).toHaveBeenCalledTimes(1);
expect(harness.installUpdate).toHaveBeenCalledTimes(1);
expect(harness.showTestNotification).toHaveBeenCalledTimes(1);
expect(harness.copyRedactedDiagnostics).toHaveBeenCalledWith(status());
});

View file

@ -47,11 +47,30 @@ describe('desktop lifecycle config', () => {
expect(env.VERITAS_ADMIN_KEY).toBe('desktop-keychain-admin-key');
expect(env.VERITAS_JWT_SECRET).toBe('desktop-keychain-jwt-secret');
expect(env.VERITAS_STORAGE).toBe('sqlite');
expect(env.VERITAS_DESKTOP_RUNTIME).toBe('0');
expect(env.DATA_DIR).toBe('/tmp/veritas-desktop/data');
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('runs packaged server with the Electron binary in Node mode from resources', () => {
const configs = createManagedProcessConfigs({
...options(),
isPackaged: true,
resourcesPath: '/Applications/Veritas Kanban.app/Contents/Resources',
});
expect(configs).toHaveLength(1);
expect(configs[0]?.args).toEqual([
'/Applications/Veritas Kanban.app/Contents/Resources/server/dist/index.js',
]);
expect(configs[0]?.cwd).toBe('/Applications/Veritas Kanban.app/Contents/Resources/server');
expect(configs[0]?.env.ELECTRON_RUN_AS_NODE).toBe('1');
expect(configs[0]?.env.VERITAS_DESKTOP_RUNTIME).toBe('1');
expect(configs[0]?.env.VERITAS_AUTH_ENABLED).toBe('true');
});
it('points web dev proxies at the selected server port', () => {
const env = buildWebEnvironment(options());

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { createDesktopMenuTemplate } from '../menu.js';
import type { DesktopStatusSnapshot } from '../types.js';
import type { DesktopUpdateStatus } from '../../shared/desktop-bridge-contracts.js';
function status(state: DesktopStatusSnapshot['server']['state'] = 'ready'): DesktopStatusSnapshot {
return {
@ -30,6 +31,15 @@ function status(state: DesktopStatusSnapshot['server']['state'] = 'ready'): Desk
};
}
function updateStatus(state: DesktopUpdateStatus['state']): DesktopUpdateStatus {
return {
state,
currentVersion: '4.3.2',
channel: 'stable',
checkedAt: '2026-05-31T00:00:00.000Z',
};
}
describe('desktop native menu', () => {
it('exposes common actions with keyboard shortcuts', () => {
const dispatch = vi.fn();
@ -65,4 +75,21 @@ describe('desktop native menu', () => {
expect(externalTest?.enabled).toBe(false);
});
it('keeps update install actions tied to updater state', () => {
const appMenu = createDesktopMenuTemplate({
status: status(),
updateStatus: updateStatus('available'),
dispatch: vi.fn(),
}).find((item) => item.label === 'Veritas Kanban');
const downloadUpdate = Array.isArray(appMenu?.submenu)
? appMenu.submenu.find((item) => item.label === 'Download Update')
: null;
const installUpdate = Array.isArray(appMenu?.submenu)
? appMenu.submenu.find((item) => item.label === 'Install Update')
: null;
expect(downloadUpdate?.enabled).toBe(true);
expect(installUpdate?.enabled).toBe(false);
});
});

View file

@ -0,0 +1,73 @@
import { mkdtemp } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { ProcessSupervisor } from '../process-supervisor.js';
import type { DesktopProcessState, ManagedProcessConfig } from '../types.js';
async function createConfig(args: string[]): Promise<ManagedProcessConfig> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'veritas-process-supervisor-'));
return {
name: 'server',
command: process.execPath,
args,
cwd: process.cwd(),
env: process.env,
logFile: path.join(tempDir, 'server.log'),
};
}
function waitForState(
supervisor: ProcessSupervisor,
state: DesktopProcessState
): Promise<ReturnType<ProcessSupervisor['snapshot']>> {
const snapshot = supervisor.snapshot();
if (snapshot.state === state) {
return Promise.resolve(snapshot);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
supervisor.off('state', onState);
reject(new Error(`Timed out waiting for ${state}`));
}, 2_000);
const onState = (next: ReturnType<ProcessSupervisor['snapshot']>) => {
if (next.state === state) {
clearTimeout(timeout);
supervisor.off('state', onState);
resolve(next);
}
};
supervisor.on('state', onState);
});
}
describe('ProcessSupervisor', () => {
it('fails when a process exits before readiness, even with code 0', async () => {
const supervisor = new ProcessSupervisor(await createConfig(['-e', 'process.exit(0)']));
const failed = waitForState(supervisor, 'failed');
await supervisor.start();
const snapshot = await failed;
expect(snapshot.lastError).toContain('server exited before becoming ready with code 0');
});
it('treats a clean exit after readiness as stopped', async () => {
const supervisor = new ProcessSupervisor(
await createConfig(['-e', 'setTimeout(() => process.exit(0), 50)'])
);
const stopped = waitForState(supervisor, 'stopped');
await supervisor.start();
supervisor.markReady();
const snapshot = await stopped;
expect(snapshot.lastError).toBeNull();
});
});

View file

@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest';
import {
DesktopUpdateService,
resolveDesktopUpdateChannel,
type DesktopUpdateAdapter,
type DesktopUpdateAdapterConfigureOptions,
} from '../updates.js';
type UpdateListener = (...args: unknown[]) => void;
class FakeUpdateAdapter implements DesktopUpdateAdapter {
configure = vi.fn((options: DesktopUpdateAdapterConfigureOptions) => {
this.config = options;
});
checkForUpdates = vi.fn(async () => null);
downloadUpdate = vi.fn(async () => []);
quitAndInstall = vi.fn();
active = true;
config: DesktopUpdateAdapterConfigureOptions | null = null;
private readonly listeners = new Map<string, UpdateListener[]>();
on(event: string, listener: UpdateListener): void {
const existing = this.listeners.get(event) ?? [];
this.listeners.set(event, [...existing, listener]);
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
isUpdaterActive(): boolean {
return this.active;
}
}
function service(adapter = new FakeUpdateAdapter()) {
const emitStatus = vi.fn();
return {
adapter,
emitStatus,
service: new DesktopUpdateService({
adapter,
packaged: true,
currentVersion: '4.3.2',
channel: 'stable',
now: () => new Date('2026-05-31T00:00:00.000Z'),
emitStatus,
}),
};
}
describe('desktop update service', () => {
it('configures updater for manual download and stable channel release checks', () => {
const harness = service();
expect(harness.adapter.configure).toHaveBeenCalledWith({
allowPrerelease: false,
autoDownload: false,
autoInstallOnAppQuit: false,
channel: 'stable',
forceDevUpdateConfig: false,
});
expect(harness.service.snapshot()).toMatchObject({
state: 'idle',
currentVersion: '4.3.2',
channel: 'stable',
});
});
it('emits available, downloading, and ready states from updater events', async () => {
const harness = service();
await harness.service.checkForUpdates();
harness.adapter.emit('update-available', { version: '4.3.3' });
harness.adapter.emit('download-progress', { percent: 55.2 });
harness.adapter.emit('update-downloaded', { version: '4.3.3' });
expect(harness.emitStatus).toHaveBeenCalledWith(
expect.objectContaining({ state: 'available', availableVersion: '4.3.3' })
);
expect(harness.emitStatus).toHaveBeenCalledWith(
expect.objectContaining({ state: 'downloading', detail: '55% downloaded.' })
);
expect(harness.service.snapshot()).toMatchObject({
state: 'ready',
availableVersion: '4.3.3',
});
});
it('keeps dev builds unsupported unless force dev update config is enabled', async () => {
const adapter = new FakeUpdateAdapter();
const updateService = new DesktopUpdateService({
adapter,
packaged: false,
currentVersion: '4.3.2',
channel: 'dev',
});
await expect(updateService.checkForUpdates()).resolves.toMatchObject({
state: 'unsupported',
});
expect(adapter.checkForUpdates).not.toHaveBeenCalled();
});
it('redacts sensitive update errors before publishing status', async () => {
const harness = service();
harness.adapter.checkForUpdates.mockRejectedValueOnce(
new Error('download failed token=abc123 path=/Users/bradgroux/private')
);
await harness.service.checkForUpdates();
expect(harness.service.snapshot()).toMatchObject({
state: 'failed',
detail: 'download failed token=[redacted] path=/Users/[redacted]/private',
});
});
it('resolves stable, beta, and dev channels conservatively', () => {
expect(resolveDesktopUpdateChannel(undefined, '4.3.2', true)).toBe('stable');
expect(resolveDesktopUpdateChannel(undefined, '5.0.0-beta.1', true)).toBe('beta');
expect(resolveDesktopUpdateChannel('dev', '4.3.2', true)).toBe('dev');
expect(resolveDesktopUpdateChannel(undefined, '4.3.2', false)).toBe('dev');
});
});

View file

@ -4,6 +4,7 @@ import { DESKTOP_APP_ID, DESKTOP_APP_NAME } from './app-metadata.js';
import type { DesktopCommandDispatcher } from './commands.js';
import type { DesktopAppInfo } from './types.js';
import type { DesktopRuntime } from './runtime.js';
import type { DesktopUpdateService } from './updates.js';
import {
createDesktopSetupDiagnostics,
createDesktopSupportSnapshot,
@ -36,7 +37,8 @@ export function createDesktopBridgeHandlers(
runtime: DesktopRuntime,
shell: Shell,
packaged: boolean,
commandDispatcher?: DesktopCommandDispatcher
commandDispatcher?: DesktopCommandDispatcher,
updateService?: DesktopUpdateService
): DesktopBridgeHandlerMap {
const appInfo = (): DesktopAppInfo => ({
name: DESKTOP_APP_NAME,
@ -65,13 +67,14 @@ export function createDesktopBridgeHandlers(
return runtime.restartLocalServer();
},
getSupportSnapshot: () => createDesktopSupportSnapshot(runtime.snapshot()),
getUpdateStatus: () => ({
state: 'unsupported',
currentVersion: appInfo().version,
channel: packaged ? 'stable' : 'dev',
checkedAt: new Date().toISOString(),
detail: 'Updater implementation is tracked in the desktop release pipeline issue.',
}),
getUpdateStatus: () =>
updateService?.snapshot() ?? {
state: 'unsupported',
currentVersion: appInfo().version,
channel: packaged ? 'stable' : 'dev',
checkedAt: new Date().toISOString(),
detail: 'Updater service is not initialized.',
},
dispatchCommand: (request) => {
const command = validateDesktopCommandDispatchRequest(request);
return commandDispatcher
@ -128,9 +131,16 @@ export function registerDesktopBridge(
runtime: DesktopRuntime,
shell: Shell,
packaged: boolean,
commandDispatcher?: DesktopCommandDispatcher
commandDispatcher?: DesktopCommandDispatcher,
updateService?: DesktopUpdateService
): void {
const handlers = createDesktopBridgeHandlers(runtime, shell, packaged, commandDispatcher);
const handlers = createDesktopBridgeHandlers(
runtime,
shell,
packaged,
commandDispatcher,
updateService
);
for (const method of DESKTOP_BRIDGE_METHOD_NAMES) {
const definition = DESKTOP_BRIDGE_METHODS[method];

View file

@ -19,6 +19,8 @@ export type DesktopCommandNativeAction =
| 'show-diagnostics'
| 'create-debug-bundle'
| 'check-updates'
| 'download-update'
| 'install-update'
| 'test-notification'
| 'test-external-delivery'
| 'copy-diagnostics'
@ -74,6 +76,10 @@ function commandLabel(name: DesktopCommandName): string {
return 'Create Debug Bundle';
case 'check-for-updates':
return 'Check for Updates';
case 'download-update':
return 'Download Update';
case 'install-update':
return 'Install Update';
case 'test-notification':
return 'Test Local Notification';
case 'test-squad-webhook':
@ -116,6 +122,10 @@ function commandNativeAction(name: DesktopCommandName): DesktopCommandNativeActi
return 'create-debug-bundle';
case 'check-for-updates':
return 'check-updates';
case 'download-update':
return 'download-update';
case 'install-update':
return 'install-update';
case 'test-notification':
return 'test-notification';
case 'test-squad-webhook':
@ -138,7 +148,9 @@ export interface DesktopCommandDispatcherOptions {
shell: Shell;
quit(): void;
sendRendererCommand(command: DesktopCommandDispatchRequest): void;
sendUpdateStatus(status: DesktopUpdateStatus): void;
checkForUpdates(): Promise<DesktopUpdateStatus>;
downloadUpdate(): Promise<DesktopUpdateStatus>;
installUpdate(): DesktopUpdateStatus;
showTestNotification(): void;
copyRedactedDiagnostics(status: DesktopStatusSnapshot): void;
}
@ -166,13 +178,13 @@ export class DesktopCommandDispatcher {
this.options.sendRendererCommand(request);
return accepted(request, 'renderer');
case 'check-updates':
this.options.sendUpdateStatus({
state: 'unsupported',
currentVersion: process.env.npm_package_version || '0.0.0',
channel: 'dev',
checkedAt: new Date().toISOString(),
detail: 'Updater implementation is tracked in the desktop release pipeline issue.',
});
await this.options.checkForUpdates();
return accepted(request, 'desktop');
case 'download-update':
await this.options.downloadUpdate();
return accepted(request, 'desktop');
case 'install-update':
this.options.installUpdate();
return accepted(request, 'desktop');
case 'test-notification':
this.options.showTestNotification();

View file

@ -1,6 +1,7 @@
import { app, BrowserWindow, clipboard, ipcMain, Notification, safeStorage, shell } from 'electron';
import path from 'node:path';
import { mkdirSync } from 'node:fs';
import { createRequire } from 'node:module';
import { DESKTOP_APP_ID, DESKTOP_APP_NAME, DESKTOP_MIN_WINDOW } from './app-metadata.js';
import { registerDesktopBridge } from './bridge.js';
@ -13,9 +14,15 @@ import { findAvailablePort } from './ports.js';
import { DesktopRuntime } from './runtime.js';
import { DesktopSecretStore } from './secrets.js';
import { statusPageUrl } from './status-page.js';
import {
DesktopUpdateService,
ElectronAutoUpdaterAdapter,
resolveDesktopUpdateChannel,
} from './updates.js';
import {
DESKTOP_BRIDGE_EVENTS,
redactDesktopBridgeValue,
type DesktopUpdateStatus,
} from '../shared/desktop-bridge-contracts.js';
import {
applyDesktopWindowState,
@ -25,9 +32,13 @@ import {
type DesktopWindowState,
} from './window-state.js';
const require = createRequire(import.meta.url);
const { autoUpdater } = require('electron-updater') as typeof import('electron-updater');
let mainWindow: BrowserWindow | null = null;
let runtime: DesktopRuntime | null = null;
let commandDispatcher: DesktopCommandDispatcher | null = null;
let updateService: DesktopUpdateService | null = null;
let windowStatePaths: ReturnType<typeof createDesktopPaths> | null = null;
let quitting = false;
let shutdownStarted = false;
@ -133,6 +144,32 @@ function flushPendingDeepLinks(): void {
}
}
function refreshDesktopMenu(): void {
if (!runtime || !commandDispatcher) {
return;
}
configureDesktopMenu({
status: runtime.snapshot(),
updateStatus: updateService?.snapshot(),
dispatch: (command) => {
if (commandDispatcher) {
dispatchDesktopMenuCommand(commandDispatcher, command);
}
},
});
}
function updateServiceFallback(packaged: boolean): DesktopUpdateStatus {
return {
state: 'unsupported',
currentVersion: app.getVersion(),
channel: packaged ? 'stable' : 'dev',
checkedAt: new Date().toISOString(),
detail: 'Updater service is not initialized.',
};
}
async function boot(): Promise<void> {
app.setName(DESKTOP_APP_NAME);
app.setAppUserModelId(DESKTOP_APP_ID);
@ -182,6 +219,7 @@ async function boot(): Promise<void> {
runtime = new DesktopRuntime({
repoRoot,
resourcesPath: process.resourcesPath,
paths,
serverPort,
webPort,
@ -198,6 +236,21 @@ async function boot(): Promise<void> {
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.notificationAction.channel, request);
}
);
updateService = new DesktopUpdateService({
adapter: new ElectronAutoUpdaterAdapter(autoUpdater),
packaged,
currentVersion: app.getVersion(),
channel: resolveDesktopUpdateChannel(
process.env.VERITAS_UPDATE_CHANNEL,
app.getVersion(),
packaged
),
forceDevUpdateConfig: process.env.VERITAS_DESKTOP_UPDATER_FORCE_DEV === 'true',
emitStatus: (status) => {
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.updateStatus.channel, status);
refreshDesktopMenu();
},
});
commandDispatcher = new DesktopCommandDispatcher({
runtime,
@ -206,9 +259,11 @@ async function boot(): Promise<void> {
sendRendererCommand: (command) => {
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.menuCommand.channel, command);
},
sendUpdateStatus: (status) => {
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.updateStatus.channel, status);
},
checkForUpdates: () =>
updateService?.checkForUpdates() ?? Promise.resolve(updateServiceFallback(packaged)),
downloadUpdate: () =>
updateService?.downloadUpdate() ?? Promise.resolve(updateServiceFallback(packaged)),
installUpdate: () => updateService?.installUpdate() ?? updateServiceFallback(packaged),
showTestNotification: () => {
notifications.show({
id: `setup-test-${Date.now()}`,
@ -224,25 +279,11 @@ async function boot(): Promise<void> {
},
});
registerDesktopBridge(ipcMain, runtime, shell, packaged, commandDispatcher);
configureDesktopMenu({
status: runtime.snapshot(),
dispatch: (command) => {
if (commandDispatcher) {
dispatchDesktopMenuCommand(commandDispatcher, command);
}
},
});
registerDesktopBridge(ipcMain, runtime, shell, packaged, commandDispatcher, updateService);
refreshDesktopMenu();
runtime.on('status', (status) => {
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.serverStatus.channel, status);
configureDesktopMenu({
status,
dispatch: (command) => {
if (commandDispatcher) {
dispatchDesktopMenuCommand(commandDispatcher, command);
}
},
});
refreshDesktopMenu();
});
try {

View file

@ -4,6 +4,7 @@ import type { DesktopPaths, DesktopRuntimeSecrets, ManagedProcessConfig } from '
export interface DesktopLifecycleOptions {
repoRoot: string;
resourcesPath?: string;
paths: DesktopPaths;
serverPort: number;
webPort: number;
@ -22,13 +23,16 @@ export function buildServerEnvironment(options: DesktopLifecycleOptions): NodeJS
return {
...process.env,
NODE_ENV: options.isPackaged ? 'production' : 'development',
...(options.isPackaged ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
HOST: '127.0.0.1',
PORT: String(options.serverPort),
VERITAS_ADMIN_KEY: options.secrets.adminKey,
VERITAS_JWT_SECRET: options.secrets.jwtSecret,
VERITAS_AUTH_ENABLED: options.isPackaged ? 'true' : 'false',
VERITAS_AUTH_LOCALHOST_BYPASS: 'false',
VERITAS_DESKTOP_RUNTIME: options.isPackaged ? '1' : '0',
VERITAS_STORAGE: 'sqlite',
DATA_DIR: options.paths.dataDir,
VERITAS_DATA_DIR: options.paths.dataDir,
VERITAS_DISABLE_WATCHERS: '1',
CORS_ORIGINS: `${serverOrigin},${webOrigin},http://localhost:${options.webPort}`,
@ -49,15 +53,18 @@ export function buildWebEnvironment(options: DesktopLifecycleOptions): NodeJS.Pr
export function createManagedProcessConfigs(
options: DesktopLifecycleOptions
): ManagedProcessConfig[] {
const packagedServerRoot = options.resourcesPath
? path.join(options.resourcesPath, 'server')
: path.join(options.repoRoot, 'server');
const packagedServerEntry =
process.env.VERITAS_DESKTOP_SERVER_ENTRY || path.join(packagedServerRoot, 'dist', 'index.js');
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,
args: [packagedServerEntry],
cwd: packagedServerRoot,
env: buildServerEnvironment(options),
logFile: path.join(options.paths.logsDir, 'server.log'),
readyUrl: `http://127.0.0.1:${options.serverPort}/api/health`,

View file

@ -6,11 +6,15 @@ import {
type DesktopCommandDispatcher,
} from './commands.js';
import type { DesktopStatusSnapshot } from './types.js';
import type { DesktopCommandName } from '../shared/desktop-bridge-contracts.js';
import type {
DesktopCommandName,
DesktopUpdateStatus,
} from '../shared/desktop-bridge-contracts.js';
export interface ConfigureDesktopMenuOptions {
dispatch(command: DesktopCommandName): void;
status: DesktopStatusSnapshot;
updateStatus?: DesktopUpdateStatus;
}
export function configureDesktopMenu(options: ConfigureDesktopMenuOptions): void {
@ -25,7 +29,7 @@ export function createDesktopMenuTemplate(
return {
label: definition.label,
accelerator: definition.accelerator,
enabled: isCommandEnabled(name, options.status),
enabled: isCommandEnabled(name, options.status, options.updateStatus),
click: () => options.dispatch(name),
};
};
@ -38,6 +42,8 @@ export function createDesktopMenuTemplate(
command('communication-health'),
{ type: 'separator' },
command('check-for-updates'),
command('download-update'),
command('install-update'),
{ type: 'separator' },
command('quit'),
],
@ -78,7 +84,11 @@ export function dispatchDesktopMenuCommand(
void dispatcher.dispatch(createDesktopCommandRequest(command, 'menu'));
}
function isCommandEnabled(command: DesktopCommandName, status: DesktopStatusSnapshot): boolean {
function isCommandEnabled(
command: DesktopCommandName,
status: DesktopStatusSnapshot,
updateStatus?: DesktopUpdateStatus
): boolean {
if (command === 'restart-local-server') {
return status.mode === 'local-dev' || status.mode === 'local-production';
}
@ -88,5 +98,11 @@ function isCommandEnabled(command: DesktopCommandName, status: DesktopStatusSnap
if (command === 'test-squad-webhook') {
return status.server.state === 'ready';
}
if (command === 'download-update') {
return updateStatus?.state === 'available';
}
if (command === 'install-update') {
return updateStatus?.state === 'ready';
}
return true;
}

View file

@ -62,13 +62,17 @@ export class ProcessSupervisor extends EventEmitter {
child.once('exit', (code, signal) => {
this.exitedAt = new Date().toISOString();
this.log(`[desktop] exited code=${code ?? 'null'} signal=${signal ?? 'null'}\n`);
const wasReady = this.state === 'ready';
this.child = null;
this.closeLogStream();
if (this.stopping || code === 0) {
if (this.stopping || (code === 0 && wasReady)) {
this.setState('stopped');
return;
}
this.lastError = `${this.config.name} exited unexpectedly with code ${code ?? 'null'} signal ${signal ?? 'null'}`;
const exitDetail = `code ${code ?? 'null'} signal ${signal ?? 'null'}`;
this.lastError = wasReady
? `${this.config.name} exited unexpectedly with ${exitDetail}`
: `${this.config.name} exited before becoming ready with ${exitDetail}`;
this.setState('failed');
});
}

View file

@ -9,6 +9,7 @@ import type { DesktopPaths, DesktopRuntimeSecrets, DesktopStatusSnapshot } from
export interface DesktopRuntimeOptions {
repoRoot: string;
resourcesPath?: string;
paths: DesktopPaths;
serverPort: number;
webPort: number;
@ -75,12 +76,12 @@ export class DesktopRuntime extends EventEmitter {
await this.ensureDirectories();
await this.writeRuntimeState();
await this.server.start();
await this.waitForReady(this.serverOrigin + '/api/health', 'server');
await this.waitForReady(this.serverOrigin + '/api/health', 'server', this.server);
this.server.markReady();
if (this.web) {
await this.web.start();
await this.waitForReady(this.rendererOrigin, 'web');
await this.waitForReady(this.rendererOrigin, 'web', this.web);
this.web.markReady();
}
@ -89,7 +90,7 @@ export class DesktopRuntime extends EventEmitter {
async restartLocalServer(): Promise<DesktopStatusSnapshot> {
await this.server.restart();
await this.waitForReady(this.serverOrigin + '/api/health', 'server');
await this.waitForReady(this.serverOrigin + '/api/health', 'server', this.server);
this.server.markReady();
this.emitStatus();
return this.snapshot();
@ -126,11 +127,23 @@ export class DesktopRuntime extends EventEmitter {
);
}
private async waitForReady(url: string, label: string): Promise<void> {
private async waitForReady(
url: string,
label: string,
supervisor: ProcessSupervisor
): Promise<void> {
const deadline = Date.now() + 45_000;
let lastError: string | null = null;
while (Date.now() < deadline) {
const processSnapshot = supervisor.snapshot();
if (processSnapshot.state === 'failed' || processSnapshot.state === 'stopped') {
const detail = processSnapshot.lastError ? `: ${processSnapshot.lastError}` : '';
this.lastError = `${label} stopped before becoming ready${detail}`;
this.emitStatus();
throw new Error(this.lastError);
}
try {
const response = await fetch(url);
if (response.ok) {

256
desktop/src/main/updates.ts Normal file
View file

@ -0,0 +1,256 @@
import type { AppUpdater } from 'electron-updater';
import {
redactSensitiveString,
type DesktopUpdateStatus,
} from '../shared/desktop-bridge-contracts.js';
type DesktopUpdateEvent =
| 'checking-for-update'
| 'update-not-available'
| 'update-available'
| 'download-progress'
| 'update-downloaded'
| 'error';
export interface DesktopUpdateAdapterConfigureOptions {
allowPrerelease: boolean;
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
channel: DesktopUpdateStatus['channel'];
forceDevUpdateConfig: boolean;
}
export interface DesktopUpdateAdapter {
configure(options: DesktopUpdateAdapterConfigureOptions): void;
on(event: DesktopUpdateEvent, listener: (...args: unknown[]) => void): void;
checkForUpdates(): Promise<unknown>;
downloadUpdate(): Promise<unknown>;
quitAndInstall(): void;
isUpdaterActive(): boolean;
}
export interface DesktopUpdateServiceOptions {
adapter: DesktopUpdateAdapter;
channel: DesktopUpdateStatus['channel'];
currentVersion: string;
emitStatus?: (status: DesktopUpdateStatus) => void;
forceDevUpdateConfig?: boolean;
packaged: boolean;
now?: () => Date;
}
export class ElectronAutoUpdaterAdapter implements DesktopUpdateAdapter {
constructor(private readonly updater: AppUpdater) {}
configure(options: DesktopUpdateAdapterConfigureOptions): void {
this.updater.autoDownload = options.autoDownload;
this.updater.autoInstallOnAppQuit = options.autoInstallOnAppQuit;
this.updater.allowPrerelease = options.allowPrerelease;
this.updater.channel = options.channel === 'stable' ? null : options.channel;
this.updater.forceDevUpdateConfig = options.forceDevUpdateConfig;
}
on(event: DesktopUpdateEvent, listener: (...args: unknown[]) => void): void {
this.updater.on(event, listener as never);
}
checkForUpdates(): Promise<unknown> {
return this.updater.checkForUpdates();
}
downloadUpdate(): Promise<unknown> {
return this.updater.downloadUpdate();
}
quitAndInstall(): void {
this.updater.quitAndInstall();
}
isUpdaterActive(): boolean {
return this.updater.isUpdaterActive();
}
}
export class DesktopUpdateService {
private readonly enabled: boolean;
private status: DesktopUpdateStatus;
constructor(private readonly options: DesktopUpdateServiceOptions) {
this.enabled = options.packaged || options.forceDevUpdateConfig === true;
this.status = this.enabled
? this.createStatus('idle', 'Update checks are ready.')
: this.createStatus('unsupported', 'Updater checks run only from packaged builds.');
options.adapter.configure({
allowPrerelease: options.channel !== 'stable',
autoDownload: false,
autoInstallOnAppQuit: false,
channel: options.channel,
forceDevUpdateConfig: options.forceDevUpdateConfig === true,
});
this.bindEvents();
}
snapshot(): DesktopUpdateStatus {
return this.status;
}
async checkForUpdates(): Promise<DesktopUpdateStatus> {
if (!this.canRunUpdater()) {
return this.status;
}
this.setStatus('checking', 'Checking for updates.');
try {
await this.options.adapter.checkForUpdates();
if (this.status.state === 'checking') {
this.setStatus('idle', 'No update metadata was returned.');
}
} catch (error) {
this.setStatus('failed', redactUpdateError(error));
}
return this.status;
}
async downloadUpdate(): Promise<DesktopUpdateStatus> {
if (!this.canRunUpdater()) {
return this.status;
}
this.setStatus('downloading', 'Downloading update.');
try {
await this.options.adapter.downloadUpdate();
if (this.status.state === 'downloading') {
this.setStatus('ready', 'Update downloaded and ready to install.');
}
} catch (error) {
this.setStatus('failed', redactUpdateError(error));
}
return this.status;
}
installUpdate(): DesktopUpdateStatus {
if (!this.canRunUpdater()) {
return this.status;
}
if (this.status.state !== 'ready') {
this.setStatus('failed', 'No downloaded update is ready to install.');
return this.status;
}
this.options.adapter.quitAndInstall();
this.setStatus('ready', 'Installing update.');
return this.status;
}
private bindEvents(): void {
this.options.adapter.on('checking-for-update', () => {
this.setStatus('checking', 'Checking for updates.');
});
this.options.adapter.on('update-not-available', () => {
this.setStatus('idle', 'Already running the latest version.');
});
this.options.adapter.on('update-available', (info) => {
this.setStatus(
'available',
`Update ${readUpdateVersion(info) ?? 'available'} is available.`,
readUpdateVersion(info)
);
});
this.options.adapter.on('download-progress', (info) => {
const percent = readDownloadPercent(info);
this.setStatus(
'downloading',
percent === null ? 'Downloading update.' : `${percent}% downloaded.`
);
});
this.options.adapter.on('update-downloaded', (info) => {
this.setStatus(
'ready',
`Update ${readUpdateVersion(info) ?? 'downloaded'} is ready to install.`,
readUpdateVersion(info)
);
});
this.options.adapter.on('error', (error) => {
this.setStatus('failed', redactUpdateError(error));
});
}
private canRunUpdater(): boolean {
if (!this.enabled) {
this.setStatus('unsupported', 'Updater checks run only from packaged builds.');
return false;
}
if (!this.options.adapter.isUpdaterActive()) {
this.setStatus('unsupported', 'Updater provider is not active for this build.');
return false;
}
return true;
}
private setStatus(
state: DesktopUpdateStatus['state'],
detail: string,
availableVersion?: string
): void {
this.status = this.createStatus(state, detail, availableVersion);
this.options.emitStatus?.(this.status);
}
private createStatus(
state: DesktopUpdateStatus['state'],
detail: string,
availableVersion?: string
): DesktopUpdateStatus {
return {
state,
currentVersion: this.options.currentVersion,
availableVersion,
channel: this.options.channel,
checkedAt: (this.options.now ?? (() => new Date()))().toISOString(),
detail,
};
}
}
export function resolveDesktopUpdateChannel(
requested: string | undefined,
currentVersion: string,
packaged: boolean
): DesktopUpdateStatus['channel'] {
const normalized = requested?.trim().toLowerCase();
if (normalized === 'stable' || normalized === 'beta' || normalized === 'dev') {
return normalized;
}
if (/\b(alpha|beta|rc|next|canary|dev)\b/i.test(currentVersion)) {
return 'beta';
}
return packaged ? 'stable' : 'dev';
}
function readUpdateVersion(value: unknown): string | undefined {
if (!isRecord(value)) {
return undefined;
}
const version = value.version;
return typeof version === 'string' && version.trim() ? version.trim() : undefined;
}
function readDownloadPercent(value: unknown): number | null {
if (!isRecord(value) || typeof value.percent !== 'number' || !Number.isFinite(value.percent)) {
return null;
}
return Math.max(0, Math.min(100, Math.round(value.percent)));
}
function redactUpdateError(error: unknown): string {
return redactSensitiveString(error instanceof Error ? error.message : String(error));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -95,6 +95,8 @@ export const DESKTOP_COMMAND_NAMES = [
'show-diagnostics',
'create-debug-bundle',
'check-for-updates',
'download-update',
'install-update',
'test-notification',
'test-squad-webhook',
'copy-redacted-diagnostics',
@ -161,6 +163,7 @@ export interface DesktopDiagnosticsBundleResult {
export interface DesktopUpdateStatus {
state: 'unsupported' | 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'failed';
currentVersion: string;
availableVersion?: string;
channel: 'dev' | 'beta' | 'stable';
checkedAt: string;
detail?: string;

117
docs/DESKTOP-RELEASE.md Normal file
View file

@ -0,0 +1,117 @@
# Veritas Kanban Desktop Release
This guide covers the v5 macOS desktop packaging path: unsigned PR artifacts,
signed/notarized release artifacts, update metadata, and smoke testing.
## Local Commands
Run these from the repository root:
```bash
pnpm desktop:package:mac:dir
pnpm desktop:package:mac:unsigned
pnpm desktop:release:mac
```
`desktop:package:mac:dir` creates an unpacked local app for fast inspection.
`desktop:package:mac:unsigned` creates unsigned DMG/ZIP artifacts and update
metadata for PR validation. `desktop:release:mac` expects signing and
notarization credentials and publishes update metadata through electron-builder.
The package step builds the workspace, stages the production server runtime in
`desktop/.desktop-release/server`, stages the built web app in
`desktop/.desktop-release/web`, and writes artifacts to `desktop/release/`.
Both staging and release directories are ignored by git.
## GitHub Workflows
`Desktop Artifacts` runs on desktop/server/web/shared changes and on manual
dispatch. It builds unsigned macOS artifacts on `macos-15`, uploads the DMG,
ZIP, blockmap, and update YAML files, and does not require Apple credentials.
`Desktop Release` runs on manual dispatch or a published GitHub release. It
requires the signing secrets below, builds signed/notarized macOS artifacts,
and publishes update metadata with the GitHub provider.
## Required Release Secrets
Configure these repository secrets before running `Desktop Release`:
- `MACOS_CSC_LINK`: base64 encoded `.p12` Developer ID Application certificate
or a secure URL accepted by electron-builder `CSC_LINK`.
- `MACOS_CSC_KEY_PASSWORD`: password for the `.p12` signing identity.
- `APPLE_ID`: Apple Developer account email for notarization.
- `APPLE_APP_SPECIFIC_PASSWORD`: app-specific password for notarization.
- `APPLE_TEAM_ID`: Apple Developer team ID.
The workflow maps those secrets to electron-builder's `CSC_LINK`,
`CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and
`APPLE_TEAM_ID` environment variables.
## Update Channels
The app uses `electron-updater` with manual download/install behavior:
- `stable`: default packaged release channel.
- `beta`: prerelease/test channel.
- `dev`: explicit development channel for controlled test metadata.
Set `VERITAS_UPDATE_CHANNEL=stable|beta|dev` in release workflows or local
packaged test runs. Dev-mode update checks remain unsupported unless
`VERITAS_DESKTOP_UPDATER_FORCE_DEV=true` is set with a valid dev update config.
The native desktop bridge exposes update status events for checking,
available, downloading, ready, failed, and unsupported states. The menu enables
download only when an update is available and install only when an update has
downloaded.
## Release Checklist
- Bump all workspace package versions together.
- Update `CHANGELOG.md`.
- Run `pnpm typecheck`, `pnpm lint:budget`, `pnpm build`, and
`pnpm test:unit`.
- Run `pnpm desktop:package:mac:unsigned` and inspect artifact names.
- Run `Desktop Artifacts` and download the uploaded DMG/ZIP/update metadata.
- Run `Desktop Release` only after Apple signing secrets are configured.
- Confirm notarization succeeds and the DMG installs without Gatekeeper
warnings on a clean Mac.
- Confirm a first run creates the profile/workspace app data directories.
- Confirm update check, download, install, failed-download, and rollback paths
on the selected channel.
## Smoke Tests
Unsigned PR artifact:
1. Download `veritas-kanban-mac-unsigned` from the workflow run.
2. Mount the DMG and drag Veritas Kanban into `/Applications`.
3. For unsigned local artifacts only, use right-click Open or remove quarantine
with `xattr -dr com.apple.quarantine "/Applications/Veritas Kanban.app"`.
4. Launch the app and confirm the desktop status page reaches the local app.
5. Confirm app data appears under
`~/Library/Application Support/@veritas-kanban/desktop/profiles/default/workspaces/local/`.
Signed release artifact:
1. Install the DMG on a clean Mac.
2. Launch normally. There should be no Gatekeeper warning.
3. Confirm local server health through the desktop UI and logs.
4. Check for updates from the native menu.
5. Publish a higher test-channel build, then confirm available, downloading,
ready, and install states.
Rollback:
1. Quit Veritas Kanban.
2. Install the previous signed DMG.
3. Launch and confirm the existing profile/workspace data is preserved.
4. If an update artifact is bad, remove or supersede the affected GitHub
release assets and publish corrected update metadata.
## Future Targets
Linux and Windows packages are intentionally not v5 Mac GA blockers. The
current packaging config keeps artifact naming and update-channel conventions
portable, but Windows signing, Linux package formats, auto-launch behavior, and
OS-specific smoke tests should be handled in follow-up issues.

View file

@ -16,6 +16,9 @@
"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:package:mac:dir": "pnpm build && pnpm --filter @veritas-kanban/desktop package:mac:dir",
"desktop:package:mac:unsigned": "pnpm build && pnpm --filter @veritas-kanban/desktop package:mac:unsigned",
"desktop:release:mac": "pnpm build && pnpm --filter @veritas-kanban/desktop release:mac",
"desktop:test": "pnpm --filter @veritas-kanban/desktop test",
"dev:clean": "bash scripts/dev-clean.sh",
"dev:watchdog": "bash scripts/dev-watchdog.sh",
@ -65,19 +68,6 @@
"prettier --write"
]
},
"pnpm": {
"overrides": {
"@xmldom/xmldom": ">=0.8.13",
"fast-uri": ">=3.1.2",
"hono": ">=4.12.18",
"ip-address": ">=10.1.1",
"postcss": ">=8.5.10",
"qs": "^6.14.2",
"minimatch": ">=10.2.3",
"path-to-regexp": ">=8.4.0",
"tmp": ">=0.2.6"
}
},
"repository": {
"type": "git",
"url": "https://github.com/BradGroux/veritas-kanban.git"

1434
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -5,3 +5,14 @@ packages:
- 'cli'
- 'mcp'
- 'desktop'
overrides:
'@xmldom/xmldom': 0.8.13
fast-uri: '>=3.1.2'
hono: '>=4.12.18'
ip-address: '>=10.1.1'
postcss: '>=8.5.10'
qs: ^6.14.2
minimatch: '>=10.2.3'
path-to-regexp: '>=8.4.0'
tmp: '>=0.2.6'

View file

@ -0,0 +1,53 @@
import { cp, rm, stat } from 'node:fs/promises';
import path from 'node:path';
async function assertDirectory(label, targetPath) {
const stats = await stat(targetPath);
if (!stats.isDirectory()) {
throw new Error(`${label} is not a directory: ${targetPath}`);
}
}
export default async function desktopAfterPack(context) {
if (context.electronPlatformName !== 'darwin') {
return;
}
const desktopDir = context.packager.projectDir;
const stagingDir = path.join(desktopDir, '.desktop-release');
const resourcesDir = path.join(
context.appOutDir,
`${context.packager.appInfo.productFilename}.app`,
'Contents',
'Resources'
);
const stagedServer = path.join(stagingDir, 'server');
const stagedWeb = path.join(stagingDir, 'web');
const packagedServer = path.join(resourcesDir, 'server');
const packagedWeb = path.join(resourcesDir, 'web');
await Promise.all([
assertDirectory('Desktop server staging', stagedServer),
assertDirectory('Desktop web staging', stagedWeb),
assertDirectory('Packaged resources', resourcesDir),
]);
await Promise.all([
rm(packagedServer, { recursive: true, force: true }),
rm(packagedWeb, { recursive: true, force: true }),
]);
await Promise.all([
cp(stagedServer, packagedServer, {
recursive: true,
force: true,
verbatimSymlinks: true,
}),
cp(stagedWeb, packagedWeb, {
recursive: true,
force: true,
verbatimSymlinks: true,
}),
]);
}

View file

@ -0,0 +1,82 @@
#!/usr/bin/env node
import { constants } from 'node:fs';
import { access, cp, mkdir, rm } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const desktopDir = path.join(rootDir, 'desktop');
const stagingDir = path.join(desktopDir, '.desktop-release');
const serverStage = path.join(stagingDir, 'server');
const webStage = path.join(stagingDir, 'web');
function pnpmCommand() {
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
}
function run(command, args) {
const result = spawnSync(command, args, {
cwd: rootDir,
encoding: 'utf8',
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
}
}
async function assertExists(label, targetPath) {
try {
await access(targetPath, constants.F_OK);
} catch {
throw new Error(`${label} is missing: ${path.relative(rootDir, targetPath)}`);
}
}
async function pruneServerDeploy() {
await Promise.all([
rm(path.join(serverStage, 'src'), { recursive: true, force: true }),
rm(path.join(serverStage, '.veritas-kanban'), { recursive: true, force: true }),
rm(path.join(serverStage, 'tsconfig.json'), { force: true }),
rm(path.join(serverStage, 'vitest.config.ts'), { force: true }),
]);
}
async function main() {
await assertExists('Server build output', path.join(rootDir, 'server/dist/index.js'));
await assertExists('Web build output', path.join(rootDir, 'web/dist/index.html'));
await rm(stagingDir, { recursive: true, force: true });
await mkdir(stagingDir, { recursive: true });
run(pnpmCommand(), [
'--filter',
'@veritas-kanban/server',
'deploy',
'--prod',
serverStage,
]);
await pruneServerDeploy();
await mkdir(webStage, { recursive: true });
await cp(path.join(rootDir, 'web/dist'), path.join(webStage, 'dist'), {
recursive: true,
});
await assertExists('Packaged server entry', path.join(serverStage, 'dist/index.js'));
await assertExists('Packaged server dependencies', path.join(serverStage, 'node_modules'));
await assertExists('Packaged web app', path.join(webStage, 'dist/index.html'));
console.log(`Prepared desktop release staging at ${path.relative(rootDir, stagingDir)}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View file

@ -16,6 +16,7 @@ import cors from 'cors';
import cookieParser from 'cookie-parser';
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
@ -196,6 +197,7 @@ app.set('etag', 'weak');
// Set CSP_REPORT_URI to a URL to receive violation reports (e.g.,
// https://your-domain.com/csp-report or a service like report-uri.com).
const isDev = process.env.NODE_ENV !== 'production';
const isDesktopRuntime = process.env.VERITAS_DESKTOP_RUNTIME === '1';
const cspReportOnly = process.env.CSP_REPORT_ONLY === 'true';
const cspReportUri = process.env.CSP_REPORT_URI || null;
@ -245,7 +247,9 @@ app.use(
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: isDev ? null : [],
// The packaged desktop app serves the SPA over loopback HTTP. Do not
// upgrade those local asset requests to HTTPS.
upgradeInsecureRequests: isDev || isDesktopRuntime ? null : [],
// CSP violation reporting — only included when CSP_REPORT_URI is set.
// Works with both enforced and report-only modes.
@ -486,6 +490,22 @@ app.use('/api', v1Router);
if (process.env.NODE_ENV === 'production') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const webDistPath = path.resolve(__dirname, '../../web/dist');
const indexHtmlPath = path.join(webDistPath, 'index.html');
const injectScriptNonce = (html: string, nonce: string | undefined): string =>
nonce ? html.replace(/<script(\s|>)/g, `<script nonce="${nonce}"$1`) : html;
const sendSpaIndex = async (
_req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
try {
const html = await readFile(indexHtmlPath, 'utf-8');
res.set('Cache-Control', 'no-cache');
res.type('html').send(injectScriptNonce(html, res.locals.cspNonce));
} catch (error) {
next(error);
}
};
// Hashed assets (JS/CSS/images in /assets/) — immutable, 1 year cache
app.use(
@ -501,6 +521,7 @@ if (process.env.NODE_ENV === 'production') {
// All other static files (index.html, favicon, manifest) — always revalidate
app.use(
express.static(webDistPath, {
index: false,
maxAge: 0,
etag: true,
lastModified: true,
@ -513,6 +534,8 @@ if (process.env.NODE_ENV === 'production') {
})
);
app.get('/', sendSpaIndex);
// SPA fallback: serve index.html for any non-API route
// Express 5 / path-to-regexp v8+ requires named wildcards (fixes #150)
app.get('{*path}', (_req, res, next) => {
@ -520,8 +543,7 @@ if (process.env.NODE_ENV === 'production') {
if (_req.path.startsWith('/api') || _req.path.startsWith('/ws') || _req.path === '/health') {
return next();
}
res.set('Cache-Control', 'no-cache');
res.sendFile(path.join(webDistPath, 'index.html'));
void sendSpaIndex(_req, res, next);
});
}