diff --git a/apps/renderer/src/app/app.routes.prod.ts b/apps/renderer/src/app/app.routes.prod.ts new file mode 100644 index 0000000..ee6aa44 --- /dev/null +++ b/apps/renderer/src/app/app.routes.prod.ts @@ -0,0 +1,21 @@ +import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + { + path: 'sign-in', + loadComponent: () => + import('./features/auth-signin-bridge/auth-signin-bridge-page').then( + (m) => m.AuthSigninBridgePage, + ), + }, + { + path: '', + loadComponent: () => + import('./features/home/home-page').then((m) => m.HomePage), + }, + { + path: '**', + redirectTo: '', + pathMatch: 'full', + }, +]; diff --git a/apps/renderer/src/app/app.ts b/apps/renderer/src/app/app.ts index 76e2139..48dba91 100644 --- a/apps/renderer/src/app/app.ts +++ b/apps/renderer/src/app/app.ts @@ -17,7 +17,9 @@ import { MatButtonModule } from '@angular/material/button'; import { distinctUntilChanged, map } from 'rxjs'; import { getDesktopApi } from '@electron-foundation/desktop-api'; import { AuthSessionStateService } from './services/auth-session-state.service'; +import { APP_SHELL_CONFIG } from './app-shell.config'; +const LABS_MODE_STORAGE_KEY = 'angulectron.labsMode'; type NavLink = { path: string; label: string; @@ -26,8 +28,6 @@ type NavLink = { lab?: boolean; }; -const LABS_MODE_STORAGE_KEY = 'angulectron.labsMode'; - @Component({ imports: [ RouterOutlet, @@ -48,116 +48,16 @@ export class App { private readonly breakpointObserver = inject(BreakpointObserver); private readonly destroyRef = inject(DestroyRef); private readonly authSessionState = inject(AuthSessionStateService); - private readonly navLinks: NavLink[] = [ - { path: '/', label: 'Home', icon: 'home', exact: true }, - { - path: '/material-showcase', - label: 'Material Showcase', - icon: 'palette', - lab: true, - }, - { - path: '/material-carbon-lab', - label: 'Material Carbon Lab', - icon: 'tune', - lab: true, - }, - { - path: '/carbon-showcase', - label: 'Carbon Showcase', - icon: 'view_quilt', - lab: true, - }, - { - path: '/tailwind-showcase', - label: 'Tailwind Showcase', - icon: 'waterfall_chart', - lab: true, - }, - { - path: '/form-validation-lab', - label: 'Form Validation Lab', - icon: 'fact_check', - lab: true, - }, - { - path: '/async-validation-lab', - label: 'Async Validation Lab', - icon: 'pending_actions', - lab: true, - }, - { - path: '/data-table-workbench', - label: 'Data Table Workbench', - icon: 'table_chart', - lab: true, - }, - { - path: '/theme-tokens-playground', - label: 'Theme Tokens Playground', - icon: 'format_paint', - lab: true, - }, - { - path: '/offline-retry-simulator', - label: 'Offline Retry Simulator', - icon: 'wifi_off', - lab: true, - }, - { - path: '/file-workflow-studio', - label: 'File Workflow Studio', - icon: 'schema', - lab: true, - }, - { - path: '/storage-explorer', - label: 'Storage Explorer', - icon: 'storage', - lab: true, - }, - { - path: '/api-playground', - label: 'API Playground', - icon: 'api', - lab: true, - }, - { - path: '/updates-release', - label: 'Updates & Release', - icon: 'system_update', - lab: true, - }, - { - path: '/telemetry-console', - label: 'Telemetry Console', - icon: 'analytics', - lab: true, - }, - { - path: '/ipc-diagnostics', - label: 'IPC Diagnostics', - icon: 'cable', - lab: true, - }, - { - path: '/auth-session-lab', - label: 'Auth Session Lab', - icon: 'badge', - lab: true, - }, - { - path: '/file-tools', - label: 'File Tools', - icon: 'folder_open', - lab: true, - }, - ]; + private readonly navLinks: ReadonlyArray = APP_SHELL_CONFIG.navLinks; protected readonly title = 'Angulectron'; protected readonly navOpen = signal(true); protected readonly mobileViewport = signal(false); protected readonly labsMode = signal(false); protected readonly labsModeLocked = signal(false); + protected readonly labsFeatureEnabled = APP_SHELL_CONFIG.labsFeatureEnabled; + protected readonly labsToggleLabel = APP_SHELL_CONFIG.labsToggleLabel; + protected readonly labsToggleOnLabel = APP_SHELL_CONFIG.labsToggleOnLabel; + protected readonly labsToggleOffLabel = APP_SHELL_CONFIG.labsToggleOffLabel; protected readonly visibleNavLinks = computed(() => this.navLinks.filter((item) => this.labsMode() || !item.lab), ); @@ -217,6 +117,13 @@ export class App { } private async initializeLabsModePolicy() { + if (!this.labsFeatureEnabled) { + this.labsModeLocked.set(true); + this.labsMode.set(false); + this.persistLabsModePreference(false); + return; + } + const desktop = getDesktopApi(); if (!desktop) { this.labsMode.set(this.loadLabsModePreference()); From 8705bedc6a37074acbfa0077ad5315873b689ca0 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 13:03:53 +0000 Subject: [PATCH 07/12] feat(updates): add bundled demo patch cycle for e2e proof --- apps/desktop-main/src/demo-updater.ts | 192 ++++++++++++++++++ apps/desktop-main/src/ipc/handler-context.ts | 2 + .../src/ipc/register-ipc-handlers.spec.ts | 2 + apps/desktop-main/src/ipc/updates-handlers.ts | 47 ++++- apps/desktop-main/src/main.ts | 6 + apps/desktop-preload/src/api/updates-api.ts | 18 ++ .../updates-release/updates-release-page.css | 3 + .../updates-release/updates-release-page.html | 19 ++ .../updates-release/updates-release-page.ts | 31 +++ .../desktop-api/src/lib/desktop-api.ts | 15 ++ libs/shared/contracts/src/lib/channels.ts | 1 + .../contracts/src/lib/contracts.spec.ts | 32 +++ .../contracts/src/lib/updates.contract.ts | 22 ++ 13 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 apps/desktop-main/src/demo-updater.ts diff --git a/apps/desktop-main/src/demo-updater.ts b/apps/desktop-main/src/demo-updater.ts new file mode 100644 index 0000000..a247a36 --- /dev/null +++ b/apps/desktop-main/src/demo-updater.ts @@ -0,0 +1,192 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { asSuccess, type DesktopResult } from '@electron-foundation/contracts'; + +const demoRootFolder = 'update-demo'; +const baselineVersion = '1.0.0-demo'; +const latestVersion = '1.0.1-demo'; +const baselineContent = `demo_version=${baselineVersion} +feature_flag=legacy +message=Baseline demo payload loaded on startup. +`; +const latestContent = `demo_version=${latestVersion} +feature_flag=patched +message=Patched demo payload from bundled versioned feed. +`; +const latestArtifactFile = `feature-${latestVersion}.txt`; + +type DemoFeed = { + version: string; + artifactFile: string; + sha256: string; +}; + +export type DemoUpdateStatus = { + status: 'available' | 'not-available' | 'error'; + source: 'demo'; + message?: string; + currentVersion: string; + latestVersion: string; + demoFilePath: string; +}; + +const sha256 = (input: string): string => + createHash('sha256').update(input, 'utf8').digest('hex'); + +export class DemoUpdater { + private readonly rootPath: string; + private readonly feedPath: string; + private readonly artifactPath: string; + private readonly latestPath: string; + private readonly runtimePath: string; + private readonly runtimeVersionPath: string; + + constructor(userDataPath: string) { + this.rootPath = path.join(userDataPath, demoRootFolder); + this.feedPath = path.join(this.rootPath, 'feed'); + this.artifactPath = path.join(this.feedPath, latestArtifactFile); + this.latestPath = path.join(this.feedPath, 'latest.json'); + this.runtimePath = path.join(this.rootPath, 'runtime-demo-feature.txt'); + this.runtimeVersionPath = path.join(this.rootPath, 'runtime-version.txt'); + } + + seedRuntimeWithBaseline(): void { + mkdirSync(this.rootPath, { recursive: true }); + mkdirSync(this.feedPath, { recursive: true }); + + const feed: DemoFeed = { + version: latestVersion, + artifactFile: latestArtifactFile, + sha256: sha256(latestContent), + }; + + writeFileSync(this.artifactPath, latestContent, 'utf8'); + writeFileSync(this.latestPath, JSON.stringify(feed, null, 2), 'utf8'); + + // Deterministic demo state: always begin from baseline on launch. + writeFileSync(this.runtimePath, baselineContent, 'utf8'); + writeFileSync(this.runtimeVersionPath, baselineVersion, 'utf8'); + } + + check(): DesktopResult { + try { + const currentVersion = this.getCurrentVersion(); + const feed = this.getFeed(); + const status: DemoUpdateStatus = { + status: currentVersion === feed.version ? 'not-available' : 'available', + source: 'demo', + currentVersion, + latestVersion: feed.version, + demoFilePath: this.runtimePath, + }; + if (status.status === 'available') { + status.message = `Demo patch available: ${feed.version}`; + } else { + status.message = 'Demo file is already at latest bundled version.'; + } + return asSuccess(status); + } catch (error) { + return asSuccess({ + status: 'error', + source: 'demo', + message: + error instanceof Error + ? error.message + : 'Demo update check failed unexpectedly.', + currentVersion: this.safeCurrentVersion(), + latestVersion, + demoFilePath: this.runtimePath, + }); + } + } + + applyPatch(): DesktopResult< + DemoUpdateStatus & { + applied: boolean; + } + > { + const checkResult = this.check(); + if (!checkResult.ok) { + return asSuccess({ + applied: false, + status: 'error', + source: 'demo', + message: 'Demo update check failed unexpectedly.', + currentVersion: this.safeCurrentVersion(), + latestVersion, + demoFilePath: this.runtimePath, + }); + } + + const checkData = checkResult.data; + if (checkData.status !== 'available') { + return asSuccess({ + ...checkData, + applied: false, + }); + } + + try { + const feed = this.getFeed(); + const artifact = readFileSync( + path.join(this.feedPath, feed.artifactFile), + 'utf8', + ); + const digest = sha256(artifact); + if (digest !== feed.sha256) { + return asSuccess({ + ...checkData, + applied: false, + status: 'error', + message: + 'Demo patch integrity check failed (sha256 mismatch against bundled feed).', + }); + } + + writeFileSync(this.runtimePath, artifact, 'utf8'); + writeFileSync(this.runtimeVersionPath, feed.version, 'utf8'); + return asSuccess({ + status: 'not-available', + source: 'demo', + message: `Demo patch applied to ${this.runtimePath}`, + currentVersion: feed.version, + latestVersion: feed.version, + demoFilePath: this.runtimePath, + applied: true, + }); + } catch (error) { + return asSuccess({ + ...checkData, + status: 'error', + applied: false, + message: + error instanceof Error + ? error.message + : 'Demo patch apply failed unexpectedly.', + }); + } + } + + private getCurrentVersion(): string { + return readFileSync(this.runtimeVersionPath, 'utf8').trim(); + } + + private safeCurrentVersion(): string { + try { + return this.getCurrentVersion(); + } catch { + return baselineVersion; + } + } + + private getFeed(): DemoFeed { + const payload = JSON.parse( + readFileSync(this.latestPath, 'utf8'), + ) as DemoFeed; + if (!payload.version || !payload.artifactFile || !payload.sha256) { + throw new Error('Demo feed is missing required fields.'); + } + return payload; + } +} diff --git a/apps/desktop-main/src/ipc/handler-context.ts b/apps/desktop-main/src/ipc/handler-context.ts index 7c8720d..ca57c4e 100644 --- a/apps/desktop-main/src/ipc/handler-context.ts +++ b/apps/desktop-main/src/ipc/handler-context.ts @@ -7,6 +7,7 @@ import type { } from '@electron-foundation/contracts'; import type { OidcService } from '../oidc-service'; import type { StorageGateway } from '../storage-gateway'; +import type { DemoUpdater } from '../demo-updater'; export type FileSelectionToken = { filePath: string; @@ -32,6 +33,7 @@ export type MainIpcContext = { getApiOperationDiagnostics: ( operationId: ApiInvokeRequest['payload']['operationId'], ) => DesktopResult; + getDemoUpdater: () => DemoUpdater | null; logEvent: ( level: 'debug' | 'info' | 'warn' | 'error', event: string, diff --git a/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts b/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts index 5f6b36b..67269b1 100644 --- a/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts +++ b/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts @@ -74,6 +74,7 @@ describe('registerIpcHandlers unauthorized sender integration', () => { getStorageGateway, invokeApiOperation, getApiOperationDiagnostics, + getDemoUpdater: vi.fn(() => null), logEvent: vi.fn(), }; @@ -86,6 +87,7 @@ describe('registerIpcHandlers unauthorized sender integration', () => { IPC_CHANNELS.apiInvoke, IPC_CHANNELS.storageGetItem, IPC_CHANNELS.updatesCheck, + IPC_CHANNELS.updatesApplyDemoPatch, ]; for (const channel of privilegedChannels) { diff --git a/apps/desktop-main/src/ipc/updates-handlers.ts b/apps/desktop-main/src/ipc/updates-handlers.ts index 908ecbf..3c2f337 100644 --- a/apps/desktop-main/src/ipc/updates-handlers.ts +++ b/apps/desktop-main/src/ipc/updates-handlers.ts @@ -5,6 +5,7 @@ import { autoUpdater } from 'electron-updater'; import { asSuccess, IPC_CHANNELS, + updatesApplyDemoPatchRequestSchema, updatesCheckRequestSchema, } from '@electron-foundation/contracts'; import type { MainIpcContext } from './handler-context'; @@ -26,11 +27,16 @@ export const registerUpdatesIpcHandlers = ( 'app-update.yml', ); if (!existsSync(updateConfigPath)) { - return asSuccess({ - status: 'error' as const, - message: - 'Update checks are not configured for this build. Use installer/release artifacts for update testing.', - }); + const demoUpdater = context.getDemoUpdater(); + if (!demoUpdater) { + return asSuccess({ + status: 'error' as const, + message: + 'Update checks are not configured for this build and demo updater is unavailable.', + }); + } + + return demoUpdater.check(); } const updateCheck = await autoUpdater.checkForUpdates(); @@ -45,17 +51,46 @@ export const registerUpdatesIpcHandlers = ( return asSuccess({ status: 'available' as const, message: `Update ${candidateVersion} is available.`, + source: 'native' as const, + currentVersion, + latestVersion: candidateVersion, }); } - return asSuccess({ status: 'not-available' as const }); + return asSuccess({ + status: 'not-available' as const, + source: 'native' as const, + currentVersion, + latestVersion: currentVersion, + }); } catch (error) { return asSuccess({ status: 'error' as const, message: error instanceof Error ? error.message : 'Update check failed.', + source: 'native' as const, }); } }, }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.updatesApplyDemoPatch, + schema: updatesApplyDemoPatchRequestSchema, + context, + handler: async () => { + const demoUpdater = context.getDemoUpdater(); + if (!demoUpdater) { + return asSuccess({ + applied: false, + status: 'error' as const, + source: 'demo' as const, + message: 'Demo updater is unavailable for this build.', + }); + } + + return demoUpdater.applyPatch(); + }, + }); }; diff --git a/apps/desktop-main/src/main.ts b/apps/desktop-main/src/main.ts index fb91d66..443a153 100644 --- a/apps/desktop-main/src/main.ts +++ b/apps/desktop-main/src/main.ts @@ -28,6 +28,7 @@ import { } from './runtime-config'; import { createRefreshTokenStore } from './secure-token-store'; import { StorageGateway } from './storage-gateway'; +import { DemoUpdater } from './demo-updater'; import { asFailure } from '@electron-foundation/contracts'; import { toStructuredLogLine } from '@electron-foundation/common'; @@ -47,6 +48,7 @@ let tokenCleanupTimer: NodeJS.Timeout | null = null; let storageGateway: StorageGateway | null = null; let oidcService: OidcService | null = null; let mainWindow: BrowserWindow | null = null; +let demoUpdater: DemoUpdater | null = null; const APP_VERSION = resolveAppMetadataVersion(); const logEvent = ( @@ -206,6 +208,9 @@ const bootstrap = async () => { }); const oidcConfig = loadOidcConfig(); + demoUpdater = new DemoUpdater(app.getPath('userData')); + demoUpdater.seedRuntimeWithBaseline(); + if (oidcConfig) { const refreshTokenStore = await createRefreshTokenStore({ userDataPath: app.getPath('userData'), @@ -249,6 +254,7 @@ const bootstrap = async () => { invokeApiOperation: (request) => invokeApiOperation(request), getApiOperationDiagnostics: (operationId) => getApiOperationDiagnostics(operationId), + getDemoUpdater: () => demoUpdater, logEvent, }); diff --git a/apps/desktop-preload/src/api/updates-api.ts b/apps/desktop-preload/src/api/updates-api.ts index e9ad6c0..6904c83 100644 --- a/apps/desktop-preload/src/api/updates-api.ts +++ b/apps/desktop-preload/src/api/updates-api.ts @@ -2,6 +2,8 @@ import type { DesktopUpdatesApi } from '@electron-foundation/desktop-api'; import { CONTRACT_VERSION, IPC_CHANNELS, + updatesApplyDemoPatchRequestSchema, + updatesApplyDemoPatchResponseSchema, updatesCheckRequestSchema, updatesCheckResponseSchema, } from '@electron-foundation/contracts'; @@ -22,4 +24,20 @@ export const createUpdatesApi = (): DesktopUpdatesApi => ({ updatesCheckResponseSchema, ); }, + + async applyDemoPatch() { + const correlationId = createCorrelationId(); + const request = updatesApplyDemoPatchRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.updatesApplyDemoPatch, + request, + correlationId, + updatesApplyDemoPatchResponseSchema, + ); + }, }); diff --git a/apps/renderer/src/app/features/updates-release/updates-release-page.css b/apps/renderer/src/app/features/updates-release/updates-release-page.css index 23cff2d..af8e42f 100644 --- a/apps/renderer/src/app/features/updates-release/updates-release-page.css +++ b/apps/renderer/src/app/features/updates-release/updates-release-page.css @@ -19,6 +19,9 @@ } .actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; margin-bottom: 0.75rem; } diff --git a/apps/renderer/src/app/features/updates-release/updates-release-page.html b/apps/renderer/src/app/features/updates-release/updates-release-page.html index ca3f820..267e4d2 100644 --- a/apps/renderer/src/app/features/updates-release/updates-release-page.html +++ b/apps/renderer/src/app/features/updates-release/updates-release-page.html @@ -28,9 +28,28 @@ Check For Updates +

{{ updateState() }}

+

Update Source: {{ updateSource() }}

+

+ Current Version: {{ updateCurrentVersion() }} +

+

+ Latest Version: {{ updateLatestVersion() }} +

+ @if (demoFilePath()) { +

Demo File: {{ demoFilePath() }}

+ } +

{{ demoPatchState() }}

diff --git a/apps/renderer/src/app/features/updates-release/updates-release-page.ts b/apps/renderer/src/app/features/updates-release/updates-release-page.ts index 06a94bd..733bbe1 100644 --- a/apps/renderer/src/app/features/updates-release/updates-release-page.ts +++ b/apps/renderer/src/app/features/updates-release/updates-release-page.ts @@ -17,6 +17,11 @@ export class UpdatesReleasePage { readonly appVersion = signal('N/A'); readonly contractVersion = signal('N/A'); readonly updateState = signal('Idle.'); + readonly updateSource = signal<'native' | 'demo' | 'unknown'>('unknown'); + readonly updateCurrentVersion = signal('N/A'); + readonly updateLatestVersion = signal('N/A'); + readonly demoFilePath = signal(''); + readonly demoPatchState = signal('Idle.'); constructor() { void this.loadMetadata(); @@ -36,9 +41,35 @@ export class UpdatesReleasePage { return; } + this.updateSource.set(result.data.source ?? 'unknown'); + this.updateCurrentVersion.set(result.data.currentVersion ?? 'N/A'); + this.updateLatestVersion.set(result.data.latestVersion ?? 'N/A'); + this.demoFilePath.set(result.data.demoFilePath ?? ''); this.updateState.set(result.data.message ?? result.data.status); } + async applyDemoPatch() { + const desktop = getDesktopApi(); + if (!desktop) { + this.demoPatchState.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.demoPatchState.set('Applying demo patch...'); + const result = await desktop.updates.applyDemoPatch(); + if (!result.ok) { + this.demoPatchState.set(result.error.message); + return; + } + + this.updateSource.set(result.data.source); + this.updateCurrentVersion.set(result.data.currentVersion ?? 'N/A'); + this.updateLatestVersion.set(result.data.latestVersion ?? 'N/A'); + this.demoFilePath.set(result.data.demoFilePath ?? ''); + this.demoPatchState.set(result.data.message ?? result.data.status); + await this.checkForUpdates(); + } + private async loadMetadata() { const desktop = getDesktopApi(); if (!desktop) { diff --git a/libs/platform/desktop-api/src/lib/desktop-api.ts b/libs/platform/desktop-api/src/lib/desktop-api.ts index dda2bd7..c80daec 100644 --- a/libs/platform/desktop-api/src/lib/desktop-api.ts +++ b/libs/platform/desktop-api/src/lib/desktop-api.ts @@ -59,6 +59,21 @@ export interface DesktopUpdatesApi { DesktopResult<{ status: 'available' | 'not-available' | 'error'; message?: string; + source?: 'native' | 'demo'; + currentVersion?: string; + latestVersion?: string; + demoFilePath?: string; + }> + >; + applyDemoPatch: () => Promise< + DesktopResult<{ + applied: boolean; + status: 'available' | 'not-available' | 'error'; + message?: string; + source: 'demo'; + currentVersion?: string; + latestVersion?: string; + demoFilePath?: string; }> >; } diff --git a/libs/shared/contracts/src/lib/channels.ts b/libs/shared/contracts/src/lib/channels.ts index efa7995..ab177fa 100644 --- a/libs/shared/contracts/src/lib/channels.ts +++ b/libs/shared/contracts/src/lib/channels.ts @@ -15,6 +15,7 @@ export const IPC_CHANNELS = { apiInvoke: 'api:invoke', apiGetOperationDiagnostics: 'api:get-operation-diagnostics', updatesCheck: 'updates:check', + updatesApplyDemoPatch: 'updates:apply-demo-patch', telemetryTrack: 'telemetry:track', } as const; diff --git a/libs/shared/contracts/src/lib/contracts.spec.ts b/libs/shared/contracts/src/lib/contracts.spec.ts index 3ccfd3a..7b29421 100644 --- a/libs/shared/contracts/src/lib/contracts.spec.ts +++ b/libs/shared/contracts/src/lib/contracts.spec.ts @@ -14,6 +14,10 @@ import { } from './auth.contract'; import { readTextFileRequestSchema } from './fs.contract'; import { storageSetRequestSchema } from './storage.contract'; +import { + updatesApplyDemoPatchResponseSchema, + updatesCheckResponseSchema, +} from './updates.contract'; describe('parseOrFailure', () => { it('should parse valid values', () => { @@ -282,3 +286,31 @@ describe('auth contracts', () => { expect(parsed.success).toBe(true); }); }); + +describe('updates contracts', () => { + it('accepts update check response with demo metadata', () => { + const parsed = updatesCheckResponseSchema.safeParse({ + status: 'available', + source: 'demo', + currentVersion: '1.0.0-demo', + latestVersion: '1.0.1-demo', + demoFilePath: 'C:\\Users\\demo\\update-demo\\runtime\\feature.txt', + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts demo patch response payload', () => { + const parsed = updatesApplyDemoPatchResponseSchema.safeParse({ + applied: true, + status: 'not-available', + source: 'demo', + message: 'Demo patch applied.', + currentVersion: '1.0.1-demo', + latestVersion: '1.0.1-demo', + demoFilePath: 'C:\\Users\\demo\\update-demo\\runtime\\feature.txt', + }); + + expect(parsed.success).toBe(true); + }); +}); diff --git a/libs/shared/contracts/src/lib/updates.contract.ts b/libs/shared/contracts/src/lib/updates.contract.ts index 2dd50ae..f732a13 100644 --- a/libs/shared/contracts/src/lib/updates.contract.ts +++ b/libs/shared/contracts/src/lib/updates.contract.ts @@ -2,11 +2,33 @@ import { z } from 'zod'; import { requestEnvelope, emptyPayloadSchema } from './request-envelope'; export const updatesCheckRequestSchema = requestEnvelope(emptyPayloadSchema); +export const updatesApplyDemoPatchRequestSchema = + requestEnvelope(emptyPayloadSchema); export const updatesCheckResponseSchema = z.object({ status: z.enum(['available', 'not-available', 'error']), message: z.string().optional(), + source: z.enum(['native', 'demo']).optional(), + currentVersion: z.string().optional(), + latestVersion: z.string().optional(), + demoFilePath: z.string().optional(), +}); + +export const updatesApplyDemoPatchResponseSchema = z.object({ + applied: z.boolean(), + status: z.enum(['available', 'not-available', 'error']), + message: z.string().optional(), + source: z.enum(['demo']), + currentVersion: z.string().optional(), + latestVersion: z.string().optional(), + demoFilePath: z.string().optional(), }); export type UpdatesCheckRequest = z.infer; export type UpdatesCheckResponse = z.infer; +export type UpdatesApplyDemoPatchRequest = z.infer< + typeof updatesApplyDemoPatchRequestSchema +>; +export type UpdatesApplyDemoPatchResponse = z.infer< + typeof updatesApplyDemoPatchResponseSchema +>; From 1fabd871c73e080eec10a45195878945f56309e9 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 14:39:23 +0000 Subject: [PATCH 08/12] docs(backlog): mark sprint completions and add update hardening items --- docs/05-governance/backlog.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/05-governance/backlog.md b/docs/05-governance/backlog.md index c336b16..a14f18f 100644 --- a/docs/05-governance/backlog.md +++ b/docs/05-governance/backlog.md @@ -8,7 +8,7 @@ Last reviewed: 2026-02-13 | ------ | --------------------------------------------------------------------- | -------- | -------- | ------------------------------ | ----------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | | BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | -| BL-003 | API operation compile-time typing hardening | Planned | Medium | Platform API | Transient FR document (API, archived) | Platform | Move from mostly string-based operation references to stricter compile-time typing. | +| BL-003 | API operation compile-time typing hardening | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | | BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | Transient FR document (API, archived) | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | | BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | Transient FR document (API, archived) | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | | BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Add explicit capability/role rules per storage operation. | @@ -17,20 +17,22 @@ Last reviewed: 2026-02-13 | BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | Transient FR document (Storage, archived) | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | | BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | | BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | -| BL-012 | IPC real-handler contract harness expansion | Proposed | Medium | Testing + IPC Contracts | TASK.md | Platform | Extend contract tests to execute preload/main handlers incl. timeout and correlation checks. | +| BL-012 | IPC real-handler contract harness expansion | Done | Medium | Testing + IPC Contracts | TASK.md | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | | BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | | BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | -| BL-015 | Add IdP global sign-out and token revocation flow | Planned | Medium | Security + Identity | OIDC integration | Platform + Security | Current sign-out clears local session only. Add provider logout/end-session and refresh-token revocation (where supported), plus clear UX state for local vs global sign-out. | -| BL-016 | Refactor desktop-main composition root and IPC modularization | Planned | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Split `apps/desktop-main/src/main.ts` into focused modules (`window`, `security`, `ipc/handlers`, `updates`, `startup`) and retain a thin composition root. | -| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Planned | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Split `apps/desktop-preload/src/main.ts` into shared invoke/correlation/timeout utility plus per-domain API modules. | -| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Planned | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Centralize sender authorization, schema validation, and error-envelope mapping to remove handler duplication. | +| BL-015 | Add IdP global sign-out and token revocation flow | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | +| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | +| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | +| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | | BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | | BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | | BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Proposed | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Create one source for route path, label, icon, and lab visibility used by router and nav shell. | | BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | -| BL-023 | Expand IPC integration harness for preload-main real handler paths | Planned | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Add integration coverage for unauthorized sender rejection, timeout behavior, and correlation-id propagation with real handlers. | +| BL-023 | Expand IPC integration harness for preload-main real handler paths | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | | BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. | -| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Planned | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Extend BL-003 by introducing operation-to-request/response type maps across contracts, preload API, and main gateway. | +| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | +| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | +| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | ## Status Definitions From d88521b0ebc5230569e942482c8bb91cad70a349 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 15:04:19 +0000 Subject: [PATCH 09/12] refactor(auth): extract oidc provider client from service --- apps/desktop-main/src/oidc-provider-client.ts | 86 +++++++++++++++++++ apps/desktop-main/src/oidc-service.ts | 80 +++-------------- 2 files changed, 99 insertions(+), 67 deletions(-) create mode 100644 apps/desktop-main/src/oidc-provider-client.ts diff --git a/apps/desktop-main/src/oidc-provider-client.ts b/apps/desktop-main/src/oidc-provider-client.ts new file mode 100644 index 0000000..3086807 --- /dev/null +++ b/apps/desktop-main/src/oidc-provider-client.ts @@ -0,0 +1,86 @@ +export type DiscoveryDocument = { + authorization_endpoint: string; + token_endpoint: string; + revocation_endpoint?: string; + end_session_endpoint?: string; +}; + +export type OidcFetchOperation = + | 'discovery' + | 'token_exchange' + | 'refresh' + | 'revocation'; + +type OidcProviderClientOptions = { + issuer: string; + fetchFn?: typeof fetch; +}; + +export class OidcProviderClient { + private readonly issuer: string; + private readonly fetchFn: typeof fetch; + private discoveryCache: DiscoveryDocument | null = null; + + constructor(options: OidcProviderClientOptions) { + this.issuer = options.issuer; + this.fetchFn = options.fetchFn ?? fetch; + } + + async getDiscovery(timeoutMs: number): Promise { + if (this.discoveryCache) { + return this.discoveryCache; + } + + const response = await this.requestWithTimeout( + `${this.issuer}/.well-known/openid-configuration`, + { + method: 'GET', + }, + timeoutMs, + 'discovery', + ); + + if (!response.ok) { + throw new Error(`OIDC discovery failed (${response.status}).`); + } + + const payload = (await response.json()) as Partial; + if (!payload.authorization_endpoint || !payload.token_endpoint) { + throw new Error('OIDC discovery payload is missing required endpoints.'); + } + + this.discoveryCache = { + authorization_endpoint: payload.authorization_endpoint, + token_endpoint: payload.token_endpoint, + revocation_endpoint: payload.revocation_endpoint, + end_session_endpoint: payload.end_session_endpoint, + }; + + return this.discoveryCache; + } + + async requestWithTimeout( + input: string, + init: RequestInit, + timeoutMs: number, + operation: OidcFetchOperation, + ): Promise { + const abortController = new AbortController(); + const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs); + try { + return await this.fetchFn(input, { + ...init, + signal: abortController.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + `OIDC ${operation} request timed out after ${timeoutMs}ms.`, + ); + } + throw error; + } finally { + clearTimeout(timeoutHandle); + } + } +} diff --git a/apps/desktop-main/src/oidc-service.ts b/apps/desktop-main/src/oidc-service.ts index 7d0e643..ac1a3ac 100644 --- a/apps/desktop-main/src/oidc-service.ts +++ b/apps/desktop-main/src/oidc-service.ts @@ -11,15 +11,12 @@ import { type DesktopResult, } from '@electron-foundation/contracts'; import type { OidcConfig } from './oidc-config'; +import { + OidcProviderClient, + type DiscoveryDocument, +} from './oidc-provider-client'; import type { RefreshTokenStore } from './secure-token-store'; -type DiscoveryDocument = { - authorization_endpoint: string; - token_endpoint: string; - revocation_endpoint?: string; - end_session_endpoint?: string; -}; - type TokenResponse = { access_token: string; token_type: string; @@ -174,9 +171,8 @@ export class OidcService { private readonly config: OidcConfig; private readonly tokenStore: RefreshTokenStore; private readonly openExternal: (url: string) => Promise; - private readonly fetchFn: typeof fetch; + private readonly providerClient: OidcProviderClient; private readonly logger?: OidcServiceOptions['logger']; - private discoveryCache: DiscoveryDocument | null = null; private summary: AuthSessionSummary = buildSignedOutSummary(); private tokens: ActiveTokens | null = null; private refreshTimer: NodeJS.Timeout | null = null; @@ -188,7 +184,10 @@ export class OidcService { this.config = options.config; this.tokenStore = options.tokenStore; this.openExternal = options.openExternal; - this.fetchFn = options.fetchFn ?? fetch; + this.providerClient = new OidcProviderClient({ + issuer: options.config.issuer, + fetchFn: options.fetchFn, + }); this.logger = options.logger; } @@ -504,35 +503,7 @@ export class OidcService { } private async getDiscovery(): Promise { - if (this.discoveryCache) { - return this.discoveryCache; - } - - const response = await this.fetchWithTimeout( - `${this.config.issuer}/.well-known/openid-configuration`, - { - method: 'GET', - }, - discoveryFetchTimeoutMs, - 'discovery', - ); - - if (!response.ok) { - throw new Error(`OIDC discovery failed (${response.status}).`); - } - - const payload = (await response.json()) as Partial; - if (!payload.authorization_endpoint || !payload.token_endpoint) { - throw new Error('OIDC discovery payload is missing required endpoints.'); - } - - this.discoveryCache = { - authorization_endpoint: payload.authorization_endpoint, - token_endpoint: payload.token_endpoint, - revocation_endpoint: payload.revocation_endpoint, - end_session_endpoint: payload.end_session_endpoint, - }; - return this.discoveryCache; + return this.providerClient.getDiscovery(discoveryFetchTimeoutMs); } private async exchangeCodeForTokens(input: { @@ -552,7 +523,7 @@ export class OidcService { body.set('audience', this.config.audience); } - const response = await this.fetchWithTimeout( + const response = await this.providerClient.requestWithTimeout( discovery.token_endpoint, { method: 'POST', @@ -616,7 +587,7 @@ export class OidcService { body.set('audience', this.config.audience); } - const response = await this.fetchWithTimeout( + const response = await this.providerClient.requestWithTimeout( discovery.token_endpoint, { method: 'POST', @@ -680,7 +651,7 @@ export class OidcService { body.set('token_type_hint', 'refresh_token'); body.set('client_id', this.config.clientId); - const response = await this.fetchWithTimeout( + const response = await this.providerClient.requestWithTimeout( discovery.revocation_endpoint, { method: 'POST', @@ -767,31 +738,6 @@ export class OidcService { this.scheduleRefresh(); } - private async fetchWithTimeout( - input: string, - init: RequestInit, - timeoutMs: number, - operation: 'discovery' | 'token_exchange' | 'refresh' | 'revocation', - ): Promise { - const abortController = new AbortController(); - const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs); - try { - return await this.fetchFn(input, { - ...init, - signal: abortController.signal, - }); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error( - `OIDC ${operation} request timed out after ${timeoutMs}ms.`, - ); - } - throw error; - } finally { - clearTimeout(timeoutHandle); - } - } - private async waitForAuthorizationCode( redirectUriTemplate: string, expectedState: string, From 51b8bf49ba01156ef9c634782b1f1ed49c9cd360 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 15:55:58 +0000 Subject: [PATCH 10/12] feat(renderer): establish home i18n pattern and loader merge --- apps/renderer/project.json | 5 ++ apps/renderer/public/i18n/en-US.json | 23 +----- .../src/app/features/home/home-page.html | 36 ++++----- .../src/app/features/home/home-page.ts | 75 +++++++++++-------- .../src/app/features/home/i18n/en-US.json | 27 +++++++ .../renderer/src/app/i18n/transloco-loader.ts | 51 ++++++++++++- tools/scripts/check-i18n.ts | 45 ++++++++++- 7 files changed, 187 insertions(+), 75 deletions(-) create mode 100644 apps/renderer/src/app/features/home/i18n/en-US.json diff --git a/apps/renderer/project.json b/apps/renderer/project.json index 614b9c6..0aa76b9 100644 --- a/apps/renderer/project.json +++ b/apps/renderer/project.json @@ -18,6 +18,11 @@ { "glob": "**/*", "input": "apps/renderer/public" + }, + { + "glob": "**/i18n/*.json", + "input": "apps/renderer/src/app/features", + "output": "i18n/features" } ], "styles": [ diff --git a/apps/renderer/public/i18n/en-US.json b/apps/renderer/public/i18n/en-US.json index f76d41b..0967ef4 100644 --- a/apps/renderer/public/i18n/en-US.json +++ b/apps/renderer/public/i18n/en-US.json @@ -1,22 +1 @@ -{ - "home": { - "title": "Desktop Foundation Shell", - "subtitle": "Angular 21 + Electron baseline with governed architecture boundaries.", - "desktopBridge": "Desktop bridge", - "connected": "Connected", - "disconnected": "Disconnected", - "appVersion": "App version", - "contractVersion": "Contract version", - "activeLanguage": "Active language", - "actions": "Desktop actions", - "openFile": "Open file", - "checkUpdates": "Check updates", - "trackTelemetry": "Track telemetry", - "useEnglish": "Use English", - "filePreview": "File preview", - "updateStatus": "Update status", - "telemetryStatus": "Telemetry status", - "governanceTitle": "UI Governance", - "governanceBody": "Material is primary. Tailwind handles layout. Carbon is allowed only through approved adapters." - } -} +{} diff --git a/apps/renderer/src/app/features/home/home-page.html b/apps/renderer/src/app/features/home/home-page.html index 50926fb..98a8657 100644 --- a/apps/renderer/src/app/features/home/home-page.html +++ b/apps/renderer/src/app/features/home/home-page.html @@ -1,66 +1,66 @@
-

{{ labels.title }}

-

{{ labels.subtitle }}

+

{{ labels().title }}

+

{{ labels().subtitle }}

- {{ labels.desktopBridge }}: + {{ labels().desktopBridge }}: {{ statusBadge().label === 'Connected' ? labels.connected : - labels.disconnected }}{{ desktopAvailable() ? labels().connected : labels().disconnected + }}

- {{ labels.appVersion }}: + {{ labels().appVersion }}: {{ appVersion() }}

- {{ labels.contractVersion }}: + {{ labels().contractVersion }}: {{ contractVersion() }}

- {{ labels.activeLanguage }}: + {{ labels().activeLanguage }}: {{ language() }}

-
+
@if (fileResult()) {

- {{ labels.filePreview }}: + {{ labels().filePreview }}: {{ fileResult() }}

} @if (updateResult()) {

- {{ labels.updateStatus }}: + {{ labels().updateStatus }}: {{ updateResult() }}

} @if (telemetryResult()) {

- {{ labels.telemetryStatus }}: + {{ labels().telemetryStatus }}: {{ telemetryResult() }}

}
diff --git a/apps/renderer/src/app/features/home/home-page.ts b/apps/renderer/src/app/features/home/home-page.ts index aa294ac..7ab924a 100644 --- a/apps/renderer/src/app/features/home/home-page.ts +++ b/apps/renderer/src/app/features/home/home-page.ts @@ -1,13 +1,16 @@ import { ChangeDetectionStrategy, - computed, Component, + computed, + inject, signal, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { getDesktopApi } from '@electron-foundation/desktop-api'; import { createShellBadge } from '@electron-foundation/shell'; import { MatButtonModule } from '@angular/material/button'; +import { TranslocoService } from '@jsverse/transloco'; @Component({ selector: 'app-home-page', @@ -17,29 +20,36 @@ import { MatButtonModule } from '@angular/material/button'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomePage { - readonly labels = { - title: 'Workspace Home', - subtitle: 'Desktop bridge status and quick integration actions.', - desktopBridge: 'Desktop bridge', - connected: 'Connected', - disconnected: 'Disconnected', - appVersion: 'App version', - contractVersion: 'Contract version', - activeLanguage: 'Active language', - actions: 'Actions', - openFile: 'Open file', - checkUpdates: 'Check updates', - trackTelemetry: 'Track telemetry', - useEnglish: 'Use English', - filePreview: 'File preview', - updateStatus: 'Update status', - telemetryStatus: 'Telemetry status', - governanceTitle: 'Governance', - governanceBody: - 'Track build quality, CI health, and release readiness from this dashboard.', - } as const; - - readonly language = signal('en-US'); + private readonly transloco = inject(TranslocoService); + readonly labels = toSignal(this.transloco.selectTranslateObject('home'), { + initialValue: { + title: 'home.title', + subtitle: 'home.subtitle', + desktopBridge: 'home.desktopBridge', + connected: 'home.connected', + disconnected: 'home.disconnected', + appVersion: 'home.appVersion', + contractVersion: 'home.contractVersion', + activeLanguage: 'home.activeLanguage', + actions: 'home.actions', + openFile: 'home.openFile', + checkUpdates: 'home.checkUpdates', + trackTelemetry: 'home.trackTelemetry', + useEnglish: 'home.useEnglish', + filePreview: 'home.filePreview', + updateStatus: 'home.updateStatus', + telemetryStatus: 'home.telemetryStatus', + governanceTitle: 'home.governanceTitle', + governanceBody: 'home.governanceBody', + desktopUnavailable: 'home.desktopUnavailable', + selectTextFileTitle: 'home.selectTextFileTitle', + textFiles: 'home.textFiles', + noFileSelected: 'home.noFileSelected', + eventAccepted: 'home.eventAccepted', + }, + }); + + readonly language = signal(this.transloco.getActiveLang()); readonly desktopAvailable = signal(false); readonly appVersion = signal('N/A'); readonly contractVersion = signal('N/A'); @@ -56,19 +66,22 @@ export class HomePage { } setLanguage(language: 'en-US') { + this.transloco.setActiveLang(language); this.language.set(language); } async openFile() { const desktop = getDesktopApi(); if (!desktop) { - this.fileResult.set('Desktop bridge unavailable in browser mode.'); + this.fileResult.set(this.labels().desktopUnavailable); return; } const fileDialogResult = await desktop.dialog.openFile({ - title: 'Select a text file', - filters: [{ name: 'Text files', extensions: ['txt', 'md', 'json'] }], + title: this.labels().selectTextFileTitle, + filters: [ + { name: this.labels().textFiles, extensions: ['txt', 'md', 'json'] }, + ], }); if ( @@ -76,7 +89,7 @@ export class HomePage { fileDialogResult.data.canceled || !fileDialogResult.data.fileToken ) { - this.fileResult.set('No file selected.'); + this.fileResult.set(this.labels().noFileSelected); return; } @@ -95,7 +108,7 @@ export class HomePage { async checkUpdates() { const desktop = getDesktopApi(); if (!desktop) { - this.updateResult.set('Desktop bridge unavailable in browser mode.'); + this.updateResult.set(this.labels().desktopUnavailable); return; } @@ -112,7 +125,7 @@ export class HomePage { async trackTelemetry() { const desktop = getDesktopApi(); if (!desktop) { - this.telemetryResult.set('Desktop bridge unavailable in browser mode.'); + this.telemetryResult.set(this.labels().desktopUnavailable); return; } @@ -122,7 +135,7 @@ export class HomePage { }); this.telemetryResult.set( - result.ok ? 'Event accepted.' : result.error.message, + result.ok ? this.labels().eventAccepted : result.error.message, ); } diff --git a/apps/renderer/src/app/features/home/i18n/en-US.json b/apps/renderer/src/app/features/home/i18n/en-US.json new file mode 100644 index 0000000..7ad02a4 --- /dev/null +++ b/apps/renderer/src/app/features/home/i18n/en-US.json @@ -0,0 +1,27 @@ +{ + "home": { + "title": "Desktop Foundation Shell", + "subtitle": "Angular 21 + Electron baseline with governed architecture boundaries.", + "desktopBridge": "Desktop bridge", + "connected": "Connected", + "disconnected": "Disconnected", + "appVersion": "App version", + "contractVersion": "Contract version", + "activeLanguage": "Active language", + "actions": "Desktop actions", + "openFile": "Open file", + "checkUpdates": "Check updates", + "trackTelemetry": "Track telemetry", + "useEnglish": "Use English", + "filePreview": "File preview", + "updateStatus": "Update status", + "telemetryStatus": "Telemetry status", + "governanceTitle": "UI Governance", + "governanceBody": "Material is primary. Tailwind handles layout. Carbon is allowed only through approved adapters.", + "desktopUnavailable": "Desktop bridge unavailable in browser mode.", + "selectTextFileTitle": "Select a text file", + "textFiles": "Text files", + "noFileSelected": "No file selected.", + "eventAccepted": "Event accepted." + } +} diff --git a/apps/renderer/src/app/i18n/transloco-loader.ts b/apps/renderer/src/app/i18n/transloco-loader.ts index 806c1c2..185c5c1 100644 --- a/apps/renderer/src/app/i18n/transloco-loader.ts +++ b/apps/renderer/src/app/i18n/transloco-loader.ts @@ -1,12 +1,61 @@ import { HttpClient } from '@angular/common/http'; +import { DOCUMENT } from '@angular/common'; import { inject, Injectable } from '@angular/core'; import { TranslocoLoader, type Translation } from '@jsverse/transloco'; +import { catchError, forkJoin, map, of } from 'rxjs'; + +const featureTranslationPaths = ['home/i18n']; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const mergeTranslations = ( + base: Translation, + incoming: Translation, +): Translation => { + const result: Translation = { ...base }; + for (const [key, value] of Object.entries(incoming)) { + const baseValue = result[key]; + if (isRecord(baseValue) && isRecord(value)) { + result[key] = mergeTranslations(baseValue, value); + continue; + } + + result[key] = value; + } + + return result; +}; @Injectable({ providedIn: 'root' }) export class TranslocoHttpLoader implements TranslocoLoader { private readonly http = inject(HttpClient); + private readonly document = inject(DOCUMENT); + + private buildAssetUrl(path: string): string { + return new URL(path, this.document.baseURI).toString(); + } getTranslation(lang: string) { - return this.http.get(`i18n/${lang}.json`); + const baseTranslation$ = this.http + .get(this.buildAssetUrl(`i18n/${lang}.json`)) + .pipe(catchError(() => of({} as Translation))); + + const featureTranslations$ = featureTranslationPaths.map((featurePath) => + this.http + .get( + this.buildAssetUrl(`i18n/features/${featurePath}/${lang}.json`), + ) + .pipe(catchError(() => of({} as Translation))), + ); + + return forkJoin([baseTranslation$, ...featureTranslations$]).pipe( + map((translations) => + translations.reduce( + (merged, current) => mergeTranslations(merged, current), + {} as Translation, + ), + ), + ); } } diff --git a/tools/scripts/check-i18n.ts b/tools/scripts/check-i18n.ts index 22e9b61..897c8fe 100644 --- a/tools/scripts/check-i18n.ts +++ b/tools/scripts/check-i18n.ts @@ -26,15 +26,54 @@ const schema = z.object({ telemetryStatus: z.string().min(1), governanceTitle: z.string().min(1), governanceBody: z.string().min(1), + desktopUnavailable: z.string().min(1), + selectTextFileTitle: z.string().min(1), + textFiles: z.string().min(1), + noFileSelected: z.string().min(1), + eventAccepted: z.string().min(1), }), }); -const localePath = path.resolve( +const baseLocalePath = path.resolve( __dirname, '../../apps/renderer/public/i18n/en-US.json', ); -const source = readFileSync(localePath, 'utf8'); -const parsed = schema.safeParse(JSON.parse(source)); +const homeLocalePath = path.resolve( + __dirname, + '../../apps/renderer/src/app/features/home/i18n/en-US.json', +); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const mergeObjects = ( + base: Record, + incoming: Record, +): Record => { + const output: Record = { ...base }; + for (const [key, value] of Object.entries(incoming)) { + const baseValue = output[key]; + if (isRecord(baseValue) && isRecord(value)) { + output[key] = mergeObjects(baseValue, value); + continue; + } + output[key] = value; + } + + return output; +}; + +const baseLocale = JSON.parse(readFileSync(baseLocalePath, 'utf8')) as Record< + string, + unknown +>; +const homeLocale = JSON.parse(readFileSync(homeLocalePath, 'utf8')) as Record< + string, + unknown +>; +const mergedLocale = mergeObjects(baseLocale, homeLocale); + +const parsed = schema.safeParse(mergedLocale); if (!parsed.success) { console.error('i18n validation failed'); From 4358f5c291ed967ae195149d0602495d3785dcb5 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 15:56:17 +0000 Subject: [PATCH 11/12] refactor(renderer): unify route registry and shell navigation --- apps/renderer/src/app/app-route-registry.ts | 312 ++++++++++++++++++ .../renderer/src/app/app-shell.config.prod.ts | 18 +- apps/renderer/src/app/app-shell.config.ts | 117 +------ apps/renderer/src/app/app.css | 78 ++++- apps/renderer/src/app/app.routes.ts | 150 +-------- apps/renderer/src/app/app.ts | 9 +- 6 files changed, 413 insertions(+), 271 deletions(-) create mode 100644 apps/renderer/src/app/app-route-registry.ts diff --git a/apps/renderer/src/app/app-route-registry.ts b/apps/renderer/src/app/app-route-registry.ts new file mode 100644 index 0000000..c01a220 --- /dev/null +++ b/apps/renderer/src/app/app-route-registry.ts @@ -0,0 +1,312 @@ +import { Route } from '@angular/router'; +import { + jwtCanActivateChildGuard, + jwtCanActivateGuard, + jwtCanDeactivateGuard, + jwtCanMatchGuard, +} from './guards/jwt-route.guards'; + +export type NavLink = { + path: string; + label: string; + icon: string; + exact?: boolean; + lab?: boolean; +}; + +type RouteRegistryEntry = { + path: string; + label?: string; + icon?: string; + exact?: boolean; + lab?: boolean; + nav?: boolean; + toRoute: () => Route; +}; + +const routeRegistry: ReadonlyArray = [ + { + path: 'sign-in', + nav: false, + toRoute: () => ({ + path: 'sign-in', + loadComponent: () => + import('./features/auth-signin-bridge/auth-signin-bridge-page').then( + (m) => m.AuthSigninBridgePage, + ), + }), + }, + { + path: '', + label: 'Home', + icon: 'home', + exact: true, + lab: false, + nav: true, + toRoute: () => ({ + path: '', + loadComponent: () => + import('./features/home/home-page').then((m) => m.HomePage), + }), + }, + { + path: 'material-showcase', + label: 'Material Showcase', + icon: 'palette', + lab: true, + nav: true, + toRoute: () => ({ + path: 'material-showcase', + loadComponent: () => + import('./features/material-showcase/material-showcase-page').then( + (m) => m.MaterialShowcasePage, + ), + }), + }, + { + path: 'material-carbon-lab', + label: 'Material Carbon Lab', + icon: 'tune', + lab: true, + nav: true, + toRoute: () => ({ + path: 'material-carbon-lab', + loadComponent: () => + import('./features/material-carbon-lab/material-carbon-lab-page').then( + (m) => m.MaterialCarbonLabPage, + ), + }), + }, + { + path: 'carbon-showcase', + label: 'Carbon Showcase', + icon: 'view_quilt', + lab: true, + nav: true, + toRoute: () => ({ + path: 'carbon-showcase', + loadComponent: () => + import('./features/carbon-showcase/carbon-showcase-page').then( + (m) => m.CarbonShowcasePage, + ), + }), + }, + { + path: 'tailwind-showcase', + label: 'Tailwind Showcase', + icon: 'waterfall_chart', + lab: true, + nav: true, + toRoute: () => ({ + path: 'tailwind-showcase', + loadComponent: () => + import('./features/tailwind-showcase/tailwind-showcase-page').then( + (m) => m.TailwindShowcasePage, + ), + }), + }, + { + path: 'form-validation-lab', + label: 'Form Validation Lab', + icon: 'fact_check', + lab: true, + nav: true, + toRoute: () => ({ + path: 'form-validation-lab', + loadComponent: () => + import('./features/form-validation-lab/form-validation-lab-page').then( + (m) => m.FormValidationLabPage, + ), + }), + }, + { + path: 'async-validation-lab', + label: 'Async Validation Lab', + icon: 'pending_actions', + lab: true, + nav: true, + toRoute: () => ({ + path: 'async-validation-lab', + loadComponent: () => + import( + './features/async-validation-lab/async-validation-lab-page' + ).then((m) => m.AsyncValidationLabPage), + }), + }, + { + path: 'data-table-workbench', + label: 'Data Table Workbench', + icon: 'table_chart', + lab: true, + nav: true, + toRoute: () => ({ + path: 'data-table-workbench', + loadComponent: () => + import( + './features/data-table-workbench/data-table-workbench-page' + ).then((m) => m.DataTableWorkbenchPage), + }), + }, + { + path: 'theme-tokens-playground', + label: 'Theme Tokens Playground', + icon: 'format_paint', + lab: true, + nav: true, + toRoute: () => ({ + path: 'theme-tokens-playground', + loadComponent: () => + import( + './features/theme-tokens-playground/theme-tokens-playground-page' + ).then((m) => m.ThemeTokensPlaygroundPage), + }), + }, + { + path: 'offline-retry-simulator', + label: 'Offline Retry Simulator', + icon: 'wifi_off', + lab: true, + nav: true, + toRoute: () => ({ + path: 'offline-retry-simulator', + loadComponent: () => + import( + './features/offline-retry-simulator/offline-retry-simulator-page' + ).then((m) => m.OfflineRetrySimulatorPage), + }), + }, + { + path: 'file-workflow-studio', + label: 'File Workflow Studio', + icon: 'schema', + lab: true, + nav: true, + toRoute: () => ({ + path: 'file-workflow-studio', + loadComponent: () => + import( + './features/file-workflow-studio/file-workflow-studio-page' + ).then((m) => m.FileWorkflowStudioPage), + }), + }, + { + path: 'storage-explorer', + label: 'Storage Explorer', + icon: 'storage', + lab: true, + nav: true, + toRoute: () => ({ + path: 'storage-explorer', + loadComponent: () => + import('./features/storage-explorer/storage-explorer-page').then( + (m) => m.StorageExplorerPage, + ), + }), + }, + { + path: 'api-playground', + label: 'API Playground', + icon: 'api', + lab: true, + nav: true, + toRoute: () => ({ + path: 'api-playground', + canMatch: [jwtCanMatchGuard], + canActivate: [jwtCanActivateGuard], + canActivateChild: [jwtCanActivateChildGuard], + children: [ + { + path: '', + canDeactivate: [jwtCanDeactivateGuard], + loadComponent: () => + import('./features/api-playground/api-playground-page').then( + (m) => m.ApiPlaygroundPage, + ), + }, + ], + }), + }, + { + path: 'updates-release', + label: 'Updates & Release', + icon: 'system_update', + lab: true, + nav: true, + toRoute: () => ({ + path: 'updates-release', + loadComponent: () => + import('./features/updates-release/updates-release-page').then( + (m) => m.UpdatesReleasePage, + ), + }), + }, + { + path: 'telemetry-console', + label: 'Telemetry Console', + icon: 'analytics', + lab: true, + nav: true, + toRoute: () => ({ + path: 'telemetry-console', + loadComponent: () => + import('./features/telemetry-console/telemetry-console-page').then( + (m) => m.TelemetryConsolePage, + ), + }), + }, + { + path: 'ipc-diagnostics', + label: 'IPC Diagnostics', + icon: 'cable', + lab: true, + nav: true, + toRoute: () => ({ + path: 'ipc-diagnostics', + loadComponent: () => + import('./features/ipc-diagnostics/ipc-diagnostics-page').then( + (m) => m.IpcDiagnosticsPage, + ), + }), + }, + { + path: 'auth-session-lab', + label: 'Auth Session Lab', + icon: 'badge', + lab: true, + nav: true, + toRoute: () => ({ + path: 'auth-session-lab', + loadComponent: () => + import('./features/auth-session-lab/auth-session-lab-page').then( + (m) => m.AuthSessionLabPage, + ), + }), + }, + { + path: 'file-tools', + label: 'File Tools', + icon: 'folder_open', + lab: true, + nav: true, + toRoute: () => ({ + path: 'file-tools', + loadComponent: () => + import('./features/file-tools/file-tools-page').then( + (m) => m.FileToolsPage, + ), + }), + }, +]; + +export const createAppRoutes = (): Route[] => + routeRegistry.map((entry) => entry.toRoute()); + +export const APP_NAV_LINKS: ReadonlyArray = routeRegistry + .filter((entry) => entry.nav === true) + .map((entry) => ({ + path: entry.path === '' ? '/' : `/${entry.path}`, + label: entry.label ?? '', + icon: entry.icon ?? '', + exact: entry.exact, + lab: entry.lab, + })); diff --git a/apps/renderer/src/app/app-shell.config.prod.ts b/apps/renderer/src/app/app-shell.config.prod.ts index f465beb..5ad29c0 100644 --- a/apps/renderer/src/app/app-shell.config.prod.ts +++ b/apps/renderer/src/app/app-shell.config.prod.ts @@ -1,4 +1,20 @@ -export const APP_SHELL_CONFIG = { +export type NavLink = { + path: string; + label: string; + icon: string; + exact?: boolean; + lab?: boolean; +}; + +export type AppShellConfig = { + labsFeatureEnabled: boolean; + labsToggleLabel: string; + labsToggleOnLabel: string; + labsToggleOffLabel: string; + navLinks: ReadonlyArray; +}; + +export const APP_SHELL_CONFIG: AppShellConfig = { labsFeatureEnabled: false, labsToggleLabel: '', labsToggleOnLabel: '', diff --git a/apps/renderer/src/app/app-shell.config.ts b/apps/renderer/src/app/app-shell.config.ts index 09436d4..7f2cc78 100644 --- a/apps/renderer/src/app/app-shell.config.ts +++ b/apps/renderer/src/app/app-shell.config.ts @@ -1,17 +1,12 @@ -export type NavLink = { - path: string; - label: string; - icon: string; - exact?: boolean; - lab?: boolean; -}; +import { APP_NAV_LINKS, type NavLink } from './app-route-registry'; +export type { NavLink } from './app-route-registry'; export type AppShellConfig = { labsFeatureEnabled: boolean; labsToggleLabel: string; labsToggleOnLabel: string; labsToggleOffLabel: string; - navLinks: NavLink[]; + navLinks: ReadonlyArray; }; export const APP_SHELL_CONFIG: AppShellConfig = { @@ -19,109 +14,5 @@ export const APP_SHELL_CONFIG: AppShellConfig = { labsToggleLabel: 'Labs Mode:', labsToggleOnLabel: 'On', labsToggleOffLabel: 'Off', - navLinks: [ - { path: '/', label: 'Home', icon: 'home', exact: true }, - { - path: '/material-showcase', - label: 'Material Showcase', - icon: 'palette', - lab: true, - }, - { - path: '/material-carbon-lab', - label: 'Material Carbon Lab', - icon: 'tune', - lab: true, - }, - { - path: '/carbon-showcase', - label: 'Carbon Showcase', - icon: 'view_quilt', - lab: true, - }, - { - path: '/tailwind-showcase', - label: 'Tailwind Showcase', - icon: 'waterfall_chart', - lab: true, - }, - { - path: '/form-validation-lab', - label: 'Form Validation Lab', - icon: 'fact_check', - lab: true, - }, - { - path: '/async-validation-lab', - label: 'Async Validation Lab', - icon: 'pending_actions', - lab: true, - }, - { - path: '/data-table-workbench', - label: 'Data Table Workbench', - icon: 'table_chart', - lab: true, - }, - { - path: '/theme-tokens-playground', - label: 'Theme Tokens Playground', - icon: 'format_paint', - lab: true, - }, - { - path: '/offline-retry-simulator', - label: 'Offline Retry Simulator', - icon: 'wifi_off', - lab: true, - }, - { - path: '/file-workflow-studio', - label: 'File Workflow Studio', - icon: 'schema', - lab: true, - }, - { - path: '/storage-explorer', - label: 'Storage Explorer', - icon: 'storage', - lab: true, - }, - { - path: '/api-playground', - label: 'API Playground', - icon: 'api', - lab: true, - }, - { - path: '/updates-release', - label: 'Updates & Release', - icon: 'system_update', - lab: true, - }, - { - path: '/telemetry-console', - label: 'Telemetry Console', - icon: 'analytics', - lab: true, - }, - { - path: '/ipc-diagnostics', - label: 'IPC Diagnostics', - icon: 'cable', - lab: true, - }, - { - path: '/auth-session-lab', - label: 'Auth Session Lab', - icon: 'badge', - lab: true, - }, - { - path: '/file-tools', - label: 'File Tools', - icon: 'folder_open', - lab: true, - }, - ], + navLinks: APP_NAV_LINKS, }; diff --git a/apps/renderer/src/app/app.css b/apps/renderer/src/app/app.css index 37f4189..2b6bb76 100644 --- a/apps/renderer/src/app/app.css +++ b/apps/renderer/src/app/app.css @@ -12,7 +12,7 @@ } .shell-sidenav { - width: 248px; + width: clamp(248px, 24vw, 360px); border-right: 1px solid rgb(0 0 0 / 9%); background: #fff; border-radius: 0; @@ -35,10 +35,85 @@ align-items: center; } +:host ::ng-deep .shell-sidenav .mdc-list-item__primary-text { + white-space: normal; + overflow: visible; + text-overflow: clip; + line-height: 1.3; +} + .shell-sidenav .mat-mdc-nav-list .mat-mdc-list-item .mat-icon { align-self: center; } +:host ::ng-deep .shell-container .shell-sidenav .mat-drawer-inner-container { + overflow-y: auto; + scrollbar-width: none; +} + +:host + ::ng-deep + .shell-container + .shell-sidenav + .mat-drawer-inner-container::-webkit-scrollbar { + width: 0; + height: 0; +} + +:host + ::ng-deep + .shell-container + .shell-sidenav:hover + .mat-drawer-inner-container, +:host + ::ng-deep + .shell-container + .shell-sidenav:focus-within + .mat-drawer-inner-container { + scrollbar-width: thin; +} + +:host + ::ng-deep + .shell-container + .shell-sidenav:hover + .mat-drawer-inner-container::-webkit-scrollbar, +:host + ::ng-deep + .shell-container + .shell-sidenav:focus-within + .mat-drawer-inner-container::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +:host + ::ng-deep + .shell-container + .shell-sidenav:hover + .mat-drawer-inner-container::-webkit-scrollbar-thumb, +:host + ::ng-deep + .shell-container + .shell-sidenav:focus-within + .mat-drawer-inner-container::-webkit-scrollbar-thumb { + background: rgb(0 0 0 / 24%); + border-radius: 999px; +} + +:host + ::ng-deep + .shell-container + .shell-sidenav:hover + .mat-drawer-inner-container::-webkit-scrollbar-track, +:host + ::ng-deep + .shell-container + .shell-sidenav:focus-within + .mat-drawer-inner-container::-webkit-scrollbar-track { + background: transparent; +} + .sidenav-brand { display: flex; align-items: center; @@ -85,6 +160,7 @@ @media (max-width: 1023px) { .shell-sidenav { + width: 248px; background: #fff; opacity: 1; } diff --git a/apps/renderer/src/app/app.routes.ts b/apps/renderer/src/app/app.routes.ts index fc6f75e..4446777 100644 --- a/apps/renderer/src/app/app.routes.ts +++ b/apps/renderer/src/app/app.routes.ts @@ -1,150 +1,4 @@ import { Route } from '@angular/router'; -import { - jwtCanActivateChildGuard, - jwtCanActivateGuard, - jwtCanDeactivateGuard, - jwtCanMatchGuard, -} from './guards/jwt-route.guards'; +import { createAppRoutes } from './app-route-registry'; -export const appRoutes: Route[] = [ - { - path: 'sign-in', - loadComponent: () => - import('./features/auth-signin-bridge/auth-signin-bridge-page').then( - (m) => m.AuthSigninBridgePage, - ), - }, - { - path: '', - loadComponent: () => - import('./features/home/home-page').then((m) => m.HomePage), - }, - { - path: 'material-showcase', - loadComponent: () => - import('./features/material-showcase/material-showcase-page').then( - (m) => m.MaterialShowcasePage, - ), - }, - { - path: 'material-carbon-lab', - loadComponent: () => - import('./features/material-carbon-lab/material-carbon-lab-page').then( - (m) => m.MaterialCarbonLabPage, - ), - }, - { - path: 'carbon-showcase', - loadComponent: () => - import('./features/carbon-showcase/carbon-showcase-page').then( - (m) => m.CarbonShowcasePage, - ), - }, - { - path: 'tailwind-showcase', - loadComponent: () => - import('./features/tailwind-showcase/tailwind-showcase-page').then( - (m) => m.TailwindShowcasePage, - ), - }, - { - path: 'form-validation-lab', - loadComponent: () => - import('./features/form-validation-lab/form-validation-lab-page').then( - (m) => m.FormValidationLabPage, - ), - }, - { - path: 'async-validation-lab', - loadComponent: () => - import('./features/async-validation-lab/async-validation-lab-page').then( - (m) => m.AsyncValidationLabPage, - ), - }, - { - path: 'data-table-workbench', - loadComponent: () => - import('./features/data-table-workbench/data-table-workbench-page').then( - (m) => m.DataTableWorkbenchPage, - ), - }, - { - path: 'theme-tokens-playground', - loadComponent: () => - import( - './features/theme-tokens-playground/theme-tokens-playground-page' - ).then((m) => m.ThemeTokensPlaygroundPage), - }, - { - path: 'offline-retry-simulator', - loadComponent: () => - import( - './features/offline-retry-simulator/offline-retry-simulator-page' - ).then((m) => m.OfflineRetrySimulatorPage), - }, - { - path: 'file-workflow-studio', - loadComponent: () => - import('./features/file-workflow-studio/file-workflow-studio-page').then( - (m) => m.FileWorkflowStudioPage, - ), - }, - { - path: 'storage-explorer', - loadComponent: () => - import('./features/storage-explorer/storage-explorer-page').then( - (m) => m.StorageExplorerPage, - ), - }, - { - path: 'api-playground', - canMatch: [jwtCanMatchGuard], - canActivate: [jwtCanActivateGuard], - canActivateChild: [jwtCanActivateChildGuard], - children: [ - { - path: '', - canDeactivate: [jwtCanDeactivateGuard], - loadComponent: () => - import('./features/api-playground/api-playground-page').then( - (m) => m.ApiPlaygroundPage, - ), - }, - ], - }, - { - path: 'updates-release', - loadComponent: () => - import('./features/updates-release/updates-release-page').then( - (m) => m.UpdatesReleasePage, - ), - }, - { - path: 'telemetry-console', - loadComponent: () => - import('./features/telemetry-console/telemetry-console-page').then( - (m) => m.TelemetryConsolePage, - ), - }, - { - path: 'ipc-diagnostics', - loadComponent: () => - import('./features/ipc-diagnostics/ipc-diagnostics-page').then( - (m) => m.IpcDiagnosticsPage, - ), - }, - { - path: 'auth-session-lab', - loadComponent: () => - import('./features/auth-session-lab/auth-session-lab-page').then( - (m) => m.AuthSessionLabPage, - ), - }, - { - path: 'file-tools', - loadComponent: () => - import('./features/file-tools/file-tools-page').then( - (m) => m.FileToolsPage, - ), - }, -]; +export const appRoutes: Route[] = createAppRoutes(); diff --git a/apps/renderer/src/app/app.ts b/apps/renderer/src/app/app.ts index 48dba91..61ad0f7 100644 --- a/apps/renderer/src/app/app.ts +++ b/apps/renderer/src/app/app.ts @@ -17,16 +17,9 @@ import { MatButtonModule } from '@angular/material/button'; import { distinctUntilChanged, map } from 'rxjs'; import { getDesktopApi } from '@electron-foundation/desktop-api'; import { AuthSessionStateService } from './services/auth-session-state.service'; -import { APP_SHELL_CONFIG } from './app-shell.config'; +import { APP_SHELL_CONFIG, type NavLink } from './app-shell.config'; const LABS_MODE_STORAGE_KEY = 'angulectron.labsMode'; -type NavLink = { - path: string; - label: string; - icon: string; - exact?: boolean; - lab?: boolean; -}; @Component({ imports: [ From 17b72281221bc602e67055b4e4b8c029cc8d9cf9 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 15:56:59 +0000 Subject: [PATCH 12/12] docs(governance): record sprint progress and bl-021 completion --- CURRENT-SPRINT.md | 13 +++++- PR_DRAFT.md | 88 +++++++++++++++++++++++++++++++++++ docs/05-governance/backlog.md | 2 +- 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 PR_DRAFT.md diff --git a/CURRENT-SPRINT.md b/CURRENT-SPRINT.md index acf2db9..6a40a32 100644 --- a/CURRENT-SPRINT.md +++ b/CURRENT-SPRINT.md @@ -2,7 +2,7 @@ Sprint window: 2026-02-13 onward (Sprint 2) Owner: Platform Engineering + Security + Frontend -Status: Active +Status: Active (core scope complete; stretch pending) ## Sprint Goal @@ -18,9 +18,14 @@ Advance post-refactor hardening by improving auth lifecycle completeness, IPC in - `BL-020` Complete renderer i18n migration for hardcoded user-facing strings. +## Additional Delivered Work (Unplanned but Completed) + +- Production hardening: exclude lab routes/navigation from production bundle surface. +- Update model proof: deterministic bundled-file demo patch cycle (`v1` to `v2`) with integrity check and UI diagnostics. + ## Out Of Scope (This Sprint) -- `BL-019`, `BL-021`, `BL-022`, `BL-024`. +- `BL-019`, `BL-022`, `BL-024`. ## Execution Plan (Coherent + Individually Testable) @@ -99,3 +104,7 @@ Advance post-refactor hardening by improving auth lifecycle completeness, IPC in - 2026-02-13: Completed `BL-023A` by adding real-handler unauthorized-sender integration coverage in `apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts`. - 2026-02-13: Completed `BL-023B` by adding preload invoke-client tests for malformed responses, timeout behavior, and invoke failures with correlation-id assertions (`apps/desktop-preload/src/invoke-client.spec.ts`) and wiring `desktop-preload:test` target. - 2026-02-13: Completed `BL-025A` and `BL-025B` baseline by adding operation type maps in contracts and consuming typed operation params/result signatures in desktop API/preload invoke surfaces. +- 2026-02-13: Auth lifecycle stabilization pass completed: bounded OIDC network timeouts in main auth service, auth-page initialization now surfaces true IPC errors, token diagnostics sequencing fixed to avoid startup race, and auth-lab redirect behavior corrected to honor only explicit external `returnUrl`. +- 2026-02-13: Production hardening completed by replacing production route/shell config to exclude lab routes and lab navigation/toggle from production artifacts. +- 2026-02-13: Added bundled update demo proof flow: app startup seeds local runtime demo file to `1.0.0-demo`, update check detects bundled `1.0.1-demo`, apply action validates sha256 and overwrites local demo file, and renderer surfaces source/version/path diagnostics. +- 2026-02-13: Completed `BL-021` by adding a typed renderer route registry (`app-route-registry.ts`) that derives both `app.routes.ts` and `APP_SHELL_CONFIG.navLinks`, removing duplicated route/nav metadata while retaining production route/shell file replacements. diff --git a/PR_DRAFT.md b/PR_DRAFT.md new file mode 100644 index 0000000..a639c5e --- /dev/null +++ b/PR_DRAFT.md @@ -0,0 +1,88 @@ +## Summary + +- What changed: + - Stabilized auth/session lifecycle behavior in Auth Session Lab and preload/main refresh timing. + - Refactored OIDC provider HTTP/discovery concerns into a dedicated provider client module. + - Hardened production frontend surface by excluding lab routes/navigation from production bundles. + - Added a deterministic bundled demo update cycle (`v1` -> `v2` patch) for end-to-end update model proof. + - Established renderer i18n migration pattern on Home using feature-local locale assets with merged transloco loading. + - Consolidated renderer route + nav metadata into a single typed registry (`BL-021`). + - Improved shell sidenav UX with adaptive width and interaction-driven scrollbar visibility. + - Updated governance backlog statuses to reflect completed sprint work and newly delivered hardening items. +- Why this change is needed: + - Remove auth startup inconsistencies/timeouts and incorrect auth-lab redirect behavior. + - Ensure production does not expose hidden lab routes/features in bundle/runtime UI. + - Provide a provable update mechanism demo path independent of installer-native updater infrastructure. + - Reduce frontend duplication/drift between router and nav shell configuration. + - Prove i18n migration mechanics before real feature-page rollout. +- Risk level (low/medium/high): + - Medium (touches desktop main/preload/contracts/renderer and IPC channels) + +## Change Groups + +- Docs / Governance: + - Backlog updated to mark completed items (`BL-003`, `BL-012`, `BL-015`, `BL-016`, `BL-017`, `BL-018`, `BL-023`, `BL-025`) and add `BL-026`/`BL-027`. + - Backlog updated to mark `BL-021` complete and sprint log updated with delivery notes. +- Frontend / UX: + - Auth Session Lab now reports real initialization failures and preserves in-place navigation when launched directly. + - Updates page now shows source/version diagnostics and supports `Apply Demo Patch` when source is `demo`. + - Production build now excludes lab routes/nav entries and hides labs toggle behavior. + - Home page now consumes i18n keys with component-local `i18n/en-US.json` and runtime-safe string lookups. + - Shell menu now scales wider on large breakpoints and hides scrollbars unless hover/focus interaction is present. +- Desktop Main / Preload / Contracts: + - Extracted OIDC discovery/timeout request behavior from `oidc-service.ts` into `oidc-provider-client.ts` (behavior-preserving refactor for `BL-019` first slice). + - Added `DemoUpdater` with deterministic baseline seeding on launch and SHA-256 validated patch apply. + - Added IPC channel `updates:apply-demo-patch`. + - Extended update contracts and desktop API typing with source/version/demo path metadata. + - Updates handler falls back to bundled demo updater when `app-update.yml` is not present. +- CI / Tooling: + - No workflow changes in this batch. + +## Validation + +- [x] `pnpm nx run contracts:test` +- [x] `pnpm nx run desktop-main:test` +- [x] `pnpm nx run renderer:build` +- [x] `pnpm nx run desktop-main:build` +- [x] Additional checks run: + - `pnpm nx run desktop-preload:test` + - `pnpm nx run desktop-preload:build` + - `pnpm nx run contracts:build` + - `pnpm nx run renderer:test` + - `pnpm i18n-check` + - `pnpm nx run renderer:build:development` (post-i18n and shell/nav changes) + - `pnpm nx run renderer:build:production` (post-`BL-021` route/nav registry refactor) + - `pnpm nx run renderer:lint` (existing unrelated warning only) + - `pnpm nx run desktop-main:test` (post-`BL-019` extraction) + - `pnpm nx run desktop-main:build` (post-`BL-019` extraction) + - Manual smoke: update check verified from Home and Updates page. + - Manual smoke: demo patch apply verified (`1.0.0-demo` -> `1.0.1-demo`) and deterministic reset after restart verified. + - Manual smoke: auth login lifecycle verified after OIDC provider-client extraction. + - Manual smoke: sidenav routing verified and scrollbar hidden-state behavior validated. + +## Engineering Checklist + +- [x] Conventional Commit title used +- [x] Unit/integration tests added or updated +- [x] A11y impact reviewed +- [x] I18n impact reviewed +- [x] IPC contract changes documented +- [ ] ADR added/updated for architecture-level decisions + +## Security (Required For Sensitive Changes) + +IMPORTANT: + +- If this PR touches `apps/desktop-main/**`, `apps/desktop-preload/**`, `libs/shared/contracts/**`, `.github/workflows/**`, or `docs/02-architecture/security-architecture.md`, the two items below MUST be checked to pass CI. + +- [x] Security review completed +- [x] Threat model updated or N/A explained + +### Security Notes + +- Threat model link/update: + - N/A for this increment (no new external network trust boundary introduced; demo update feed/artifact are local bundled files under app-managed userData path). +- N/A rationale (when no threat model update is needed): + - New functionality remains behind existing privileged IPC boundary checks. + - Demo patch path validates artifact integrity (sha256) and writes only to deterministic local demo file path. + - No executable code loading or dynamic plugin hot-swap introduced. diff --git a/docs/05-governance/backlog.md b/docs/05-governance/backlog.md index a14f18f..8c18d35 100644 --- a/docs/05-governance/backlog.md +++ b/docs/05-governance/backlog.md @@ -26,7 +26,7 @@ Last reviewed: 2026-02-13 | BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | | BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | | BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | -| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Proposed | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Create one source for route path, label, icon, and lab visibility used by router and nav shell. | +| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | | BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | | BL-023 | Expand IPC integration harness for preload-main real handler paths | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | | BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. |