diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f57c6212..62a40f89 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -24,6 +24,7 @@ concurrency: env: NODE_VERSION: '22' + VERITAS_BUILD_SHA: ${{ github.sha }} VERITAS_UPDATE_CHANNEL: ${{ github.event.inputs.channel || 'stable' }} jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4899f6..20bd48b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 chat scrolling cannot move the application shell, wheel input cannot resize the dock, and Close, Escape, Back, or Reset Layout always preserve a visible board and restore focus (#1004). +- Added a native **About Veritas Kanban** panel and offline **Copy Version + Information** action using Electron's authoritative application version. The + same record now reports the embedded release commit, stable/beta/development + channel, OS, and architecture through About, clipboard support text, the + desktop bridge, and updater fallback without depending on the renderer or + network (#1005). ## [6.0.1] - 2026-07-24 diff --git a/desktop/electron.vite.config.ts b/desktop/electron.vite.config.ts index c32b8935..b0b143e9 100644 --- a/desktop/electron.vite.config.ts +++ b/desktop/electron.vite.config.ts @@ -6,6 +6,12 @@ const electronRuntimeExternal = ['electron', /^electron\/.+/]; export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], + define: { + __VERITAS_BUILD_SHA__: JSON.stringify( + process.env.VERITAS_BUILD_SHA ?? process.env.GITHUB_SHA ?? '' + ), + __VERITAS_RELEASE_CHANNEL__: JSON.stringify(process.env.VERITAS_UPDATE_CHANNEL ?? ''), + }, build: { // Vite 8 builds with Rolldown. Electron Vite 5 still places its built-in // runtime externals under rollupOptions, which Rolldown does not consume. diff --git a/desktop/src/main/__tests__/bridge-contracts.test.ts b/desktop/src/main/__tests__/bridge-contracts.test.ts index 6667d50a..a55a61fc 100644 --- a/desktop/src/main/__tests__/bridge-contracts.test.ts +++ b/desktop/src/main/__tests__/bridge-contracts.test.ts @@ -138,6 +138,9 @@ describe('desktop bridge contracts', () => { expect(bridgeHandlers.getAppInfo(undefined)).toMatchObject({ name: 'Veritas Kanban', version: '6.0.1', + channel: 'stable', + arch: process.arch, + osVersion: expect.any(String), packaged: true, }); }); diff --git a/desktop/src/main/__tests__/menu.test.ts b/desktop/src/main/__tests__/menu.test.ts index 828f957e..ed1f26b6 100644 --- a/desktop/src/main/__tests__/menu.test.ts +++ b/desktop/src/main/__tests__/menu.test.ts @@ -43,7 +43,8 @@ function updateStatus(state: DesktopUpdateStatus['state']): DesktopUpdateStatus describe('desktop native menu', () => { it('exposes common actions with keyboard shortcuts', () => { const dispatch = vi.fn(); - const template = createDesktopMenuTemplate({ status: status(), dispatch }); + const copyVersionInfo = vi.fn(); + const template = createDesktopMenuTemplate({ status: status(), dispatch, copyVersionInfo }); const labels = template.flatMap((item) => Array.isArray(item.submenu) ? item.submenu.map((child) => child.label) : [] ); @@ -56,6 +57,14 @@ describe('desktop native menu', () => { expect(labels).toContain('Restart Local Server'); expect(labels).toContain('Reset Window Layout'); + const appMenu = template.find((item) => item.label === 'Veritas Kanban'); + const appItems = Array.isArray(appMenu?.submenu) ? appMenu.submenu : []; + expect(appItems[0]).toMatchObject({ role: 'about', label: 'About Veritas Kanban' }); + expect(appItems[1]).toMatchObject({ type: 'separator' }); + const copyVersion = appItems.find((item) => item.label === 'Copy Version Information'); + copyVersion?.click?.(undefined as never, undefined as never, undefined as never); + expect(copyVersionInfo).toHaveBeenCalledOnce(); + const fileMenu = template.find((item) => item.label === 'File'); const newTask = Array.isArray(fileMenu?.submenu) ? fileMenu.submenu.find((item) => item.label === 'New Task') @@ -67,7 +76,11 @@ describe('desktop native menu', () => { }); it('exposes the native edit menu so macOS text fields receive standard shortcuts', () => { - const template = createDesktopMenuTemplate({ status: status(), dispatch: vi.fn() }); + const template = createDesktopMenuTemplate({ + status: status(), + dispatch: vi.fn(), + copyVersionInfo: vi.fn(), + }); expect(template.some((item) => item.role === 'editMenu')).toBe(true); }); @@ -76,6 +89,7 @@ describe('desktop native menu', () => { const desktopMenu = createDesktopMenuTemplate({ status: status('failed'), dispatch: vi.fn(), + copyVersionInfo: vi.fn(), }).find((item) => item.label === 'Desktop'); const externalTest = Array.isArray(desktopMenu?.submenu) ? desktopMenu.submenu.find((item) => item.label === 'Test External Delivery') @@ -89,6 +103,7 @@ describe('desktop native menu', () => { status: status(), updateStatus: updateStatus('available'), dispatch: vi.fn(), + copyVersionInfo: vi.fn(), }).find((item) => item.label === 'Veritas Kanban'); const downloadUpdate = Array.isArray(appMenu?.submenu) ? appMenu.submenu.find((item) => item.label === 'Download Update') diff --git a/desktop/src/main/__tests__/version-info.test.ts b/desktop/src/main/__tests__/version-info.test.ts new file mode 100644 index 00000000..77cb19ef --- /dev/null +++ b/desktop/src/main/__tests__/version-info.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { + createDesktopAboutPanelOptions, + createDesktopAppInfo, + formatDesktopVersionInfo, + normalizeBuildIdentity, +} from '../version-info.js'; + +describe('desktop version information', () => { + it('formats authoritative packaged version, build, channel, and platform details', () => { + const info = createDesktopAppInfo('6.0.2', true, { + platform: 'darwin', + arch: 'arm64', + osVersion: '15.5', + buildIdentity: 'abc1234', + }); + + expect(info).toMatchObject({ + version: '6.0.2', + buildIdentity: 'abc1234', + channel: 'stable', + platform: 'darwin', + arch: 'arm64', + osVersion: '15.5', + packaged: true, + }); + expect(formatDesktopVersionInfo(info)).toBe( + ['Veritas Kanban 6.0.2', 'Build: abc1234', 'Channel: stable', 'macOS 15.5 · arm64'].join('\n') + ); + }); + + it('labels prerelease and development builds without network access', () => { + expect( + createDesktopAppInfo('6.0.2-beta.1', true, { + platform: 'darwin', + arch: 'arm64', + osVersion: '15.5', + buildIdentity: null, + }).channel + ).toBe('beta'); + + const development = createDesktopAppInfo('6.0.2', false, { + platform: 'darwin', + arch: 'arm64', + osVersion: '15.5', + buildIdentity: null, + }); + expect(development.channel).toBe('dev'); + expect(formatDesktopVersionInfo(development)).toContain('Build: development'); + }); + + it('rejects path-like or unbounded build metadata from support output', () => { + expect(normalizeBuildIdentity('/Users/example/private/build')).toBeNull(); + expect(normalizeBuildIdentity('a'.repeat(65))).toBeNull(); + + const info = createDesktopAppInfo('6.0.2', true, { + buildIdentity: '/Users/example/private/build', + osVersion: '15.5', + }); + expect(info.buildIdentity).toBeNull(); + expect(formatDesktopVersionInfo(info)).not.toContain('/Users/'); + }); + + it('builds an offline native About panel from the same app information', () => { + const info = createDesktopAppInfo('6.0.2', true, { + platform: 'darwin', + arch: 'arm64', + osVersion: '15.5', + buildIdentity: 'abc1234', + }); + + expect(createDesktopAboutPanelOptions(info)).toMatchObject({ + applicationName: 'Veritas Kanban', + applicationVersion: '6.0.2', + version: 'Build abc1234', + credits: expect.stringContaining('Channel: stable'), + }); + }); +}); diff --git a/desktop/src/main/bridge.ts b/desktop/src/main/bridge.ts index 0af7703b..c259ba43 100644 --- a/desktop/src/main/bridge.ts +++ b/desktop/src/main/bridge.ts @@ -2,11 +2,10 @@ import type { IpcMain, Shell } from 'electron'; import { lookup } from 'node:dns/promises'; import { blockedRemoteConnectionDestinationReason } from '@veritas-kanban/shared'; -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 { createDesktopAppInfo } from './version-info.js'; import { createDesktopSetupDiagnostics, createDesktopSupportSnapshot, @@ -209,13 +208,7 @@ export function createDesktopBridgeHandlers( updateService?: DesktopUpdateService, windowControls?: DesktopWindowControls ): DesktopBridgeHandlerMap { - const appInfo = (): DesktopAppInfo => ({ - name: DESKTOP_APP_NAME, - appId: DESKTOP_APP_ID, - version: appVersion, - platform: process.platform, - packaged, - }); + const appInfo = () => createDesktopAppInfo(appVersion, packaged); return { getAppInfo: appInfo, @@ -244,7 +237,7 @@ export function createDesktopBridgeHandlers( updateService?.snapshot() ?? { state: 'unsupported', currentVersion: appInfo().version, - channel: packaged ? 'stable' : 'dev', + channel: appInfo().channel, checkedAt: new Date().toISOString(), detail: 'Updater service is not initialized.', }, @@ -324,8 +317,7 @@ export function registerDesktopBridge( const definition = DESKTOP_BRIDGE_METHODS[method]; const handler = handlers[method] as (request: unknown) => MaybePromise; const validator = DESKTOP_BRIDGE_METHOD_VALIDATORS[method] as - | ((payload: unknown) => unknown) - | undefined; + ((payload: unknown) => unknown) | undefined; ipcMain.handle(definition.channel, async (_event, request: unknown) => { try { diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 688e0db5..efe1271c 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -20,6 +20,11 @@ import { ElectronAutoUpdaterAdapter, resolveDesktopUpdateChannel, } from './updates.js'; +import { + createDesktopAboutPanelOptions, + createDesktopAppInfo, + formatDesktopVersionInfo, +} from './version-info.js'; import { DESKTOP_BRIDGE_EVENTS, redactDesktopBridgeValue, @@ -174,9 +179,11 @@ function refreshDesktopMenu(): void { return; } + const appInfo = createDesktopAppInfo(app.getVersion(), launchPackaged); configureDesktopMenu({ status: runtime.snapshot(), updateStatus: updateService?.snapshot(), + copyVersionInfo: () => clipboard.writeText(formatDesktopVersionInfo(appInfo)), dispatch: (command) => { if (commandDispatcher) { dispatchDesktopMenuCommand(commandDispatcher, command); @@ -186,10 +193,11 @@ function refreshDesktopMenu(): void { } function updateServiceFallback(packaged: boolean): DesktopUpdateStatus { + const appInfo = createDesktopAppInfo(app.getVersion(), packaged); return { state: 'unsupported', - currentVersion: app.getVersion(), - channel: packaged ? 'stable' : 'dev', + currentVersion: appInfo.version, + channel: appInfo.channel, checkedAt: new Date().toISOString(), detail: 'Updater service is not initialized.', }; @@ -200,6 +208,8 @@ async function boot(): Promise { app.setAppUserModelId(DESKTOP_APP_ID); const packaged = launchPackaged; + const appInfo = createDesktopAppInfo(app.getVersion(), packaged); + app.setAboutPanelOptions(createDesktopAboutPanelOptions(appInfo)); const repoRoot = launchRepoRoot; const profile = launchProfile; const workspace = launchWorkspace; diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 3226bb24..b805d493 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -13,6 +13,7 @@ import type { export interface ConfigureDesktopMenuOptions { dispatch(command: DesktopCommandName): void; + copyVersionInfo(): void; status: DesktopStatusSnapshot; updateStatus?: DesktopUpdateStatus; } @@ -38,6 +39,13 @@ export function createDesktopMenuTemplate( { label: 'Veritas Kanban', submenu: [ + { role: 'about', label: 'About Veritas Kanban' }, + { type: 'separator' }, + { + label: 'Copy Version Information', + click: () => options.copyVersionInfo(), + }, + { type: 'separator' }, command('open-onboarding'), command('open-settings'), command('communication-health'), diff --git a/desktop/src/main/types.ts b/desktop/src/main/types.ts index 7584f723..10b502f0 100644 --- a/desktop/src/main/types.ts +++ b/desktop/src/main/types.ts @@ -70,6 +70,10 @@ export interface DesktopAppInfo { name: string; appId: string; version: string; + buildIdentity: string | null; + channel: 'dev' | 'beta' | 'stable'; platform: NodeJS.Platform; + arch: string; + osVersion: string; packaged: boolean; } diff --git a/desktop/src/main/version-info.ts b/desktop/src/main/version-info.ts new file mode 100644 index 00000000..0e124d19 --- /dev/null +++ b/desktop/src/main/version-info.ts @@ -0,0 +1,102 @@ +import os from 'node:os'; + +import { DESKTOP_APP_ID, DESKTOP_APP_NAME } from './app-metadata.js'; +import type { DesktopAppInfo } from './types.js'; +import { resolveDesktopUpdateChannel } from './updates.js'; + +declare const __VERITAS_BUILD_SHA__: string | undefined; +declare const __VERITAS_RELEASE_CHANNEL__: string | undefined; + +export interface DesktopAppInfoOverrides { + platform?: NodeJS.Platform; + arch?: string; + osVersion?: string; + requestedChannel?: string; + buildIdentity?: string | null; +} + +function embeddedBuildIdentity(): string | null { + const value = typeof __VERITAS_BUILD_SHA__ === 'string' ? __VERITAS_BUILD_SHA__ : undefined; + return normalizeBuildIdentity(value); +} + +function embeddedReleaseChannel(): string | undefined { + return typeof __VERITAS_RELEASE_CHANNEL__ === 'string' && __VERITAS_RELEASE_CHANNEL__.trim() + ? __VERITAS_RELEASE_CHANNEL__ + : undefined; +} + +export function normalizeBuildIdentity(value: string | undefined | null): string | null { + const normalized = value?.trim(); + if (!normalized || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(normalized)) { + return null; + } + return normalized; +} + +function resolveSystemVersion(platform: NodeJS.Platform): string { + if (platform === 'darwin') { + const electronProcess = process as NodeJS.Process & { getSystemVersion?: () => string }; + const systemVersion = electronProcess.getSystemVersion?.(); + if (systemVersion?.trim()) return systemVersion.trim(); + } + return os.release(); +} + +export function createDesktopAppInfo( + version: string, + packaged: boolean, + overrides: DesktopAppInfoOverrides = {} +): DesktopAppInfo { + const platform = overrides.platform ?? process.platform; + const buildIdentity = + overrides.buildIdentity === undefined + ? embeddedBuildIdentity() + : normalizeBuildIdentity(overrides.buildIdentity); + return { + name: DESKTOP_APP_NAME, + appId: DESKTOP_APP_ID, + version, + buildIdentity, + channel: resolveDesktopUpdateChannel( + overrides.requestedChannel ?? embeddedReleaseChannel() ?? process.env.VERITAS_UPDATE_CHANNEL, + version, + packaged + ), + platform, + arch: overrides.arch ?? process.arch, + osVersion: overrides.osVersion ?? resolveSystemVersion(platform), + packaged, + }; +} + +function platformLabel(platform: NodeJS.Platform): string { + if (platform === 'darwin') return 'macOS'; + if (platform === 'win32') return 'Windows'; + if (platform === 'linux') return 'Linux'; + return platform; +} + +export function formatDesktopVersionInfo(info: DesktopAppInfo): string { + const lines = [`${info.name} ${info.version}`]; + if (info.buildIdentity) { + lines.push(`Build: ${info.buildIdentity}`); + } else if (!info.packaged) { + lines.push('Build: development'); + } + lines.push( + `Channel: ${info.channel}`, + `${platformLabel(info.platform)} ${info.osVersion} · ${info.arch}` + ); + return lines.join('\n'); +} + +export function createDesktopAboutPanelOptions(info: DesktopAppInfo) { + const supportLines = formatDesktopVersionInfo(info).split('\n').slice(1); + return { + applicationName: info.name, + applicationVersion: info.version, + version: info.buildIdentity ? `Build ${info.buildIdentity}` : `Channel ${info.channel}`, + credits: supportLines.join('\n'), + }; +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9ba24da9..e61eca62 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -118,6 +118,7 @@ The Kanban board is the central interface — a drag-and-drop workspace that ref - **Dark/light mode** — Ships dark by default with a toggle in Settings → General → Appearance; persists to localStorage; inline script in `index.html` prevents flash of wrong theme on load - **Filter bar** — Search tasks by text, filter by project and task type; filters persist in URL query params - **Desktop shell controls** — Native-app-style toolbar with workspace selection, health state, view toggles, and bounded left/right/chat dock controls shared by the web and macOS app shells +- **Native version identity** — The macOS application menu opens an offline About panel and copies a redacted support string from the same authoritative Electron version, embedded release commit, release channel, OS, and architecture record exposed by the desktop bridge - **Mobile shell controls** — Compact navigation uses bounded labels and full accessible names; Board Chat stays fixed above the bottom navigation and device safe area - **Resizable Workbench** — Board Chat and Squad Chat open in a right dock by default, can switch to Bottom without losing the active conversation, and clamp their width or height to keep the application shell recoverable - **Bulk operations** — Select multiple tasks to move, archive, or delete in batch; select-all toggle diff --git a/docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md b/docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md index 6a8503cd..78af6dbd 100644 --- a/docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md +++ b/docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md @@ -223,3 +223,9 @@ runtime-manifest, support-profile, compatibility-matrix, launch-manifest, approval, run-event, and completion evidence. Never paste raw private relay events, provider output, credentials, or unrestricted support bundles into a public issue. + +In the packaged macOS app, choose **Veritas Kanban → About Veritas Kanban** for +the authoritative running version. **Copy Version Information** in the same +native menu produces a redacted offline support string with the build identity, +release channel, macOS version, and architecture even when the renderer or local +API is unavailable. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68ffe509..eb5717c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: '@babel/core@<7.29.1': 7.29.7 '@modelcontextprotocol/sdk>express-rate-limit': 8.5.2 '@xmldom/xmldom': 0.8.13 + brace-expansion@<=5.0.7: 5.0.8 esbuild@<0.28.1: 0.28.1 fast-uri@<3.1.4: 3.1.4 form-data@<4.0.6: 4.0.6 @@ -1969,9 +1970,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} browserslist@4.28.5: resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} @@ -6922,7 +6923,7 @@ snapshots: boolean@3.2.0: optional: true - brace-expansion@5.0.7: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -9176,7 +9177,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimist@1.2.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 13848f38..adf7d434 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ overrides: '@babel/core@<7.29.1': 7.29.7 '@modelcontextprotocol/sdk>express-rate-limit': 8.5.2 '@xmldom/xmldom': 0.8.13 + 'brace-expansion@<=5.0.7': 5.0.8 'esbuild@<0.28.1': 0.28.1 'fast-uri@<3.1.4': 3.1.4 'form-data@<4.0.6': 4.0.6