mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Add macOS desktop release pipeline (#473)
This commit is contained in:
parent
0815e97042
commit
f0dceac22f
28 changed files with 2634 additions and 113 deletions
66
.github/workflows/desktop-artifacts.yml
vendored
Normal file
66
.github/workflows/desktop-artifacts.yml
vendored
Normal 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
78
.github/workflows/desktop-release.yml
vendored
Normal 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
2
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
desktop/resources/entitlements.mac.inherit.plist
Normal file
14
desktop/resources/entitlements.mac.inherit.plist
Normal 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>
|
||||
12
desktop/resources/entitlements.mac.plist
Normal file
12
desktop/resources/entitlements.mac.plist
Normal 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>
|
||||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
73
desktop/src/main/__tests__/process-supervisor.test.ts
Normal file
73
desktop/src/main/__tests__/process-supervisor.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
128
desktop/src/main/__tests__/updates.test.ts
Normal file
128
desktop/src/main/__tests__/updates.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
256
desktop/src/main/updates.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
117
docs/DESKTOP-RELEASE.md
Normal 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.
|
||||
16
package.json
16
package.json
|
|
@ -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
1434
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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'
|
||||
|
|
|
|||
53
scripts/desktop-after-pack.mjs
Normal file
53
scripts/desktop-after-pack.mjs
Normal 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,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
82
scripts/prepare-desktop-release.mjs
Normal file
82
scripts/prepare-desktop-release.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue