diff --git a/CURRENT-SPRINT.md b/CURRENT-SPRINT.md index eda8de8..9c22c7b 100644 --- a/CURRENT-SPRINT.md +++ b/CURRENT-SPRINT.md @@ -95,3 +95,9 @@ Reduce risk in privileged runtime boundaries by completing the P0 refactor set w - 2026-02-13: Sprint initialized from governance backlog with P0 focus (`BL-016`, `BL-017`, `BL-018`). - 2026-02-13: Added PR-sized execution breakdown with per-unit proof commands. +- 2026-02-13: Completed `BL-016B` by extracting desktop-main IPC handlers into per-domain modules under `apps/desktop-main/src/ipc/*`. +- 2026-02-13: Completed `BL-018A` and `BL-018B` by introducing `registerValidatedHandler` and migrating all privileged handlers to the shared authorization/validation wrapper. +- 2026-02-13: Completed `BL-017A` by extracting preload invoke/correlation/error-mapping core into `apps/desktop-preload/src/invoke-client.ts`. +- 2026-02-13: Completed `BL-017B` by splitting preload app/auth/dialog/fs/storage/external/updates/telemetry APIs into `apps/desktop-preload/src/api/*` and reducing `apps/desktop-preload/src/main.ts` to composition-only wiring. +- 2026-02-13: Verification pass after `BL-017B` completed: `pnpm nx run desktop-preload:build`, `pnpm nx run desktop-main:build`, `pnpm nx run desktop-main:test`, and `pnpm nx run renderer:build` all passed. +- 2026-02-13: Cross-cut gate passed for the sprint batch: `pnpm unit-test`, `pnpm integration-test`, and `pnpm runtime:smoke`. diff --git a/apps/desktop-main/src/ipc/api-handlers.ts b/apps/desktop-main/src/ipc/api-handlers.ts new file mode 100644 index 0000000..18c7018 --- /dev/null +++ b/apps/desktop-main/src/ipc/api-handlers.ts @@ -0,0 +1,30 @@ +import type { IpcMain } from 'electron'; +import { + apiGetOperationDiagnosticsRequestSchema, + apiInvokeRequestSchema, + IPC_CHANNELS, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +export const registerApiIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.apiInvoke, + schema: apiInvokeRequestSchema, + context, + handler: (_event, request) => context.invokeApiOperation(request), + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.apiGetOperationDiagnostics, + schema: apiGetOperationDiagnosticsRequestSchema, + context, + handler: (_event, request) => + context.getApiOperationDiagnostics(request.payload.operationId), + }); +}; diff --git a/apps/desktop-main/src/ipc/app-handlers.ts b/apps/desktop-main/src/ipc/app-handlers.ts new file mode 100644 index 0000000..bf68065 --- /dev/null +++ b/apps/desktop-main/src/ipc/app-handlers.ts @@ -0,0 +1,46 @@ +import type { IpcMain } from 'electron'; +import { + appRuntimeVersionsRequestSchema, + appVersionRequestSchema, + asSuccess, + CONTRACT_VERSION, + handshakeRequestSchema, + IPC_CHANNELS, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +export const registerAppIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.handshake, + schema: handshakeRequestSchema, + context, + handler: () => asSuccess({ contractVersion: CONTRACT_VERSION }), + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.appGetVersion, + schema: appVersionRequestSchema, + context, + handler: () => asSuccess({ version: context.appVersion }), + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.appGetRuntimeVersions, + schema: appRuntimeVersionsRequestSchema, + context, + handler: () => + asSuccess({ + electron: process.versions.electron, + node: process.versions.node, + chrome: process.versions.chrome, + appEnvironment: context.appEnvironment, + }), + }); +}; diff --git a/apps/desktop-main/src/ipc/auth-handlers.ts b/apps/desktop-main/src/ipc/auth-handlers.ts new file mode 100644 index 0000000..3f5baf4 --- /dev/null +++ b/apps/desktop-main/src/ipc/auth-handlers.ts @@ -0,0 +1,100 @@ +import type { IpcMain } from 'electron'; +import { + asFailure, + asSuccess, + authGetSessionSummaryRequestSchema, + authGetTokenDiagnosticsRequestSchema, + authSignInRequestSchema, + authSignOutRequestSchema, + IPC_CHANNELS, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +export const registerAuthIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.authSignIn, + schema: authSignInRequestSchema, + context, + handler: (event, request) => { + const oidcService = context.getOidcService(); + if (!oidcService) { + return asFailure( + 'AUTH/NOT_CONFIGURED', + 'OIDC authentication is not configured for this build.', + undefined, + false, + request.correlationId, + ); + } + + return oidcService.signIn(); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.authSignOut, + schema: authSignOutRequestSchema, + context, + handler: () => { + const oidcService = context.getOidcService(); + if (!oidcService) { + return asSuccess({ signedOut: true }); + } + + return oidcService.signOut(); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.authGetSessionSummary, + schema: authGetSessionSummaryRequestSchema, + context, + handler: () => { + const oidcService = context.getOidcService(); + if (!oidcService) { + return asSuccess({ + state: 'signed-out' as const, + scopes: [], + entitlements: [], + }); + } + + return oidcService.getSessionSummary(); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.authGetTokenDiagnostics, + schema: authGetTokenDiagnosticsRequestSchema, + context, + handler: () => { + const oidcService = context.getOidcService(); + if (!oidcService) { + return asSuccess({ + sessionState: 'signed-out' as const, + bearerSource: 'access_token' as const, + accessToken: { + present: false, + format: 'absent' as const, + claims: null, + }, + idToken: { + present: false, + format: 'absent' as const, + claims: null, + }, + }); + } + + return oidcService.getTokenDiagnostics(); + }, + }); +}; diff --git a/apps/desktop-main/src/ipc/file-handlers.ts b/apps/desktop-main/src/ipc/file-handlers.ts new file mode 100644 index 0000000..bf9cc5f --- /dev/null +++ b/apps/desktop-main/src/ipc/file-handlers.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { BrowserWindow, dialog, type IpcMain } from 'electron'; +import { + asFailure, + asSuccess, + IPC_CHANNELS, + openFileDialogRequestSchema, + readTextFileRequestSchema, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +export const registerFileIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.dialogOpenFile, + schema: openFileDialogRequestSchema, + context, + handler: async (event, request) => { + const result = await dialog.showOpenDialog({ + title: request.payload.title, + filters: request.payload.filters, + properties: ['openFile'], + }); + + const selectedPath = result.filePaths[0]; + if (result.canceled || !selectedPath) { + return asSuccess({ canceled: true }); + } + + const windowId = BrowserWindow.fromWebContents(event.sender)?.id; + if (!windowId) { + return asFailure( + 'FS/TOKEN_CREATION_FAILED', + 'Unable to associate selected file with a window context.', + undefined, + false, + request.correlationId, + ); + } + + const fileToken = randomUUID(); + context.selectedFileTokens.set(fileToken, { + filePath: selectedPath, + windowId, + expiresAt: Date.now() + context.fileTokenTtlMs, + }); + + return asSuccess({ + canceled: false, + fileName: path.basename(selectedPath), + fileToken, + }); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.fsReadTextFile, + schema: readTextFileRequestSchema, + context, + handler: async (event, request) => { + try { + const selected = context.selectedFileTokens.get( + request.payload.fileToken, + ); + if (!selected || selected.expiresAt <= Date.now()) { + context.selectedFileTokens.delete(request.payload.fileToken); + return asFailure( + 'FS/INVALID_TOKEN', + 'The selected file token is invalid or expired.', + undefined, + false, + request.correlationId, + ); + } + + const senderWindowId = BrowserWindow.fromWebContents(event.sender)?.id; + if (senderWindowId !== selected.windowId) { + context.selectedFileTokens.delete(request.payload.fileToken); + return asFailure( + 'FS/INVALID_TOKEN_SCOPE', + 'Selected file token was issued for a different window.', + { + senderWindowId: senderWindowId ?? null, + tokenWindowId: selected.windowId, + }, + false, + request.correlationId, + ); + } + + context.selectedFileTokens.delete(request.payload.fileToken); + + const content = await fs.readFile(selected.filePath, { + encoding: request.payload.encoding, + }); + + return asSuccess({ content }); + } catch (error) { + return asFailure( + 'FS/READ_FAILED', + 'Unable to read requested file.', + error, + false, + request.correlationId, + ); + } + }, + }); +}; diff --git a/apps/desktop-main/src/ipc/handler-context.ts b/apps/desktop-main/src/ipc/handler-context.ts new file mode 100644 index 0000000..7c8720d --- /dev/null +++ b/apps/desktop-main/src/ipc/handler-context.ts @@ -0,0 +1,41 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import type { + ApiGetOperationDiagnosticsResponse, + ApiInvokeRequest, + ApiInvokeResponse, + DesktopResult, +} from '@electron-foundation/contracts'; +import type { OidcService } from '../oidc-service'; +import type { StorageGateway } from '../storage-gateway'; + +export type FileSelectionToken = { + filePath: string; + expiresAt: number; + windowId: number; +}; + +export type MainIpcContext = { + appVersion: string; + appEnvironment: 'development' | 'staging' | 'production'; + fileTokenTtlMs: number; + selectedFileTokens: Map; + getCorrelationId: (payload: unknown) => string | undefined; + assertAuthorizedSender: ( + event: IpcMainInvokeEvent, + correlationId?: string, + ) => DesktopResult | null; + getOidcService: () => OidcService | null; + getStorageGateway: () => StorageGateway; + invokeApiOperation: ( + request: ApiInvokeRequest, + ) => Promise>; + getApiOperationDiagnostics: ( + operationId: ApiInvokeRequest['payload']['operationId'], + ) => DesktopResult; + logEvent: ( + level: 'debug' | 'info' | 'warn' | 'error', + event: string, + correlationId?: string, + details?: Record, + ) => void; +}; diff --git a/apps/desktop-main/src/ipc/register-ipc-handlers.ts b/apps/desktop-main/src/ipc/register-ipc-handlers.ts new file mode 100644 index 0000000..6b587d6 --- /dev/null +++ b/apps/desktop-main/src/ipc/register-ipc-handlers.ts @@ -0,0 +1,22 @@ +import type { IpcMain } from 'electron'; +import type { MainIpcContext } from './handler-context'; +import { registerApiIpcHandlers } from './api-handlers'; +import { registerAppIpcHandlers } from './app-handlers'; +import { registerAuthIpcHandlers } from './auth-handlers'; +import { registerFileIpcHandlers } from './file-handlers'; +import { registerStorageIpcHandlers } from './storage-handlers'; +import { registerTelemetryIpcHandlers } from './telemetry-handlers'; +import { registerUpdatesIpcHandlers } from './updates-handlers'; + +export const registerIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerAppIpcHandlers(ipcMain, context); + registerAuthIpcHandlers(ipcMain, context); + registerFileIpcHandlers(ipcMain, context); + registerApiIpcHandlers(ipcMain, context); + registerStorageIpcHandlers(ipcMain, context); + registerUpdatesIpcHandlers(ipcMain, context); + registerTelemetryIpcHandlers(ipcMain, context); +}; diff --git a/apps/desktop-main/src/ipc/register-validated-handler.ts b/apps/desktop-main/src/ipc/register-validated-handler.ts new file mode 100644 index 0000000..2eec0be --- /dev/null +++ b/apps/desktop-main/src/ipc/register-validated-handler.ts @@ -0,0 +1,44 @@ +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import type { z } from 'zod'; +import { asFailure } from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; + +type RegisterValidatedHandlerArgs = { + ipcMain: IpcMain; + channel: string; + schema: TSchema; + context: MainIpcContext; + handler: ( + event: IpcMainInvokeEvent, + request: z.infer, + ) => unknown | Promise; +}; + +export const registerValidatedHandler = ({ + ipcMain, + channel, + schema, + context, + handler, +}: RegisterValidatedHandlerArgs) => { + ipcMain.handle(channel, async (event, payload) => { + const correlationId = context.getCorrelationId(payload); + const unauthorized = context.assertAuthorizedSender(event, correlationId); + if (unauthorized) { + return unauthorized; + } + + const parsed = schema.safeParse(payload); + if (!parsed.success) { + return asFailure( + 'IPC/VALIDATION_FAILED', + 'IPC payload failed validation.', + parsed.error.flatten(), + false, + correlationId, + ); + } + + return handler(event, parsed.data); + }); +}; diff --git a/apps/desktop-main/src/ipc/storage-handlers.ts b/apps/desktop-main/src/ipc/storage-handlers.ts new file mode 100644 index 0000000..6bc2ac1 --- /dev/null +++ b/apps/desktop-main/src/ipc/storage-handlers.ts @@ -0,0 +1,49 @@ +import type { IpcMain } from 'electron'; +import { + IPC_CHANNELS, + storageClearDomainRequestSchema, + storageDeleteRequestSchema, + storageGetRequestSchema, + storageSetRequestSchema, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +export const registerStorageIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.storageSetItem, + schema: storageSetRequestSchema, + context, + handler: (_event, request) => context.getStorageGateway().setItem(request), + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.storageGetItem, + schema: storageGetRequestSchema, + context, + handler: (_event, request) => context.getStorageGateway().getItem(request), + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.storageDeleteItem, + schema: storageDeleteRequestSchema, + context, + handler: (_event, request) => + context.getStorageGateway().deleteItem(request), + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.storageClearDomain, + schema: storageClearDomainRequestSchema, + context, + handler: (_event, request) => + context.getStorageGateway().clearDomain(request), + }); +}; diff --git a/apps/desktop-main/src/ipc/telemetry-handlers.ts b/apps/desktop-main/src/ipc/telemetry-handlers.ts new file mode 100644 index 0000000..dca3b0e --- /dev/null +++ b/apps/desktop-main/src/ipc/telemetry-handlers.ts @@ -0,0 +1,46 @@ +import type { IpcMain } from 'electron'; +import { + asSuccess, + IPC_CHANNELS, + telemetryTrackRequestSchema, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +const redactTelemetryProperties = ( + properties: Record | undefined, +): Record => { + if (!properties) { + return {}; + } + + const sensitiveKeyPattern = + /token|secret|password|credential|api[-_]?key|auth/i; + const redacted: Record = {}; + + for (const [key, value] of Object.entries(properties)) { + redacted[key] = sensitiveKeyPattern.test(key) ? '[REDACTED]' : value; + } + + return redacted; +}; + +export const registerTelemetryIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.telemetryTrack, + schema: telemetryTrackRequestSchema, + context, + handler: (_event, request) => { + context.logEvent('info', 'telemetry.track', request.correlationId, { + eventName: request.payload.eventName, + properties: redactTelemetryProperties(request.payload.properties), + }); + + return asSuccess({ accepted: true }); + }, + }); +}; diff --git a/apps/desktop-main/src/ipc/updates-handlers.ts b/apps/desktop-main/src/ipc/updates-handlers.ts new file mode 100644 index 0000000..908ecbf --- /dev/null +++ b/apps/desktop-main/src/ipc/updates-handlers.ts @@ -0,0 +1,61 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { app, type IpcMain } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import { + asSuccess, + IPC_CHANNELS, + updatesCheckRequestSchema, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { registerValidatedHandler } from './register-validated-handler'; + +export const registerUpdatesIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.updatesCheck, + schema: updatesCheckRequestSchema, + context, + handler: async () => { + try { + const updateConfigPath = path.join( + process.resourcesPath, + '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 updateCheck = await autoUpdater.checkForUpdates(); + const candidateVersion = updateCheck?.updateInfo?.version; + const currentVersion = app.getVersion(); + const hasUpdate = + typeof candidateVersion === 'string' && + candidateVersion.length > 0 && + candidateVersion !== currentVersion; + + if (hasUpdate) { + return asSuccess({ + status: 'available' as const, + message: `Update ${candidateVersion} is available.`, + }); + } + + return asSuccess({ status: 'not-available' as const }); + } catch (error) { + return asSuccess({ + status: 'error' as const, + message: + error instanceof Error ? error.message : 'Update check failed.', + }); + } + }, + }); +}; diff --git a/apps/desktop-main/src/main.ts b/apps/desktop-main/src/main.ts index 49191ab..fb91d66 100644 --- a/apps/desktop-main/src/main.ts +++ b/apps/desktop-main/src/main.ts @@ -1,19 +1,14 @@ import { app, BrowserWindow, - dialog, - ipcMain, Menu, safeStorage, session, shell, type IpcMainInvokeEvent, + ipcMain, } from 'electron'; -import { randomUUID } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { autoUpdater } from 'electron-updater'; import { getApiOperationDiagnostics, invokeApiOperation, @@ -23,6 +18,8 @@ import { createMainWindow as createDesktopMainWindow, isAllowedNavigation, } from './desktop-window'; +import { registerIpcHandlers } from './ipc/register-ipc-handlers'; +import type { FileSelectionToken } from './ipc/handler-context'; import { loadOidcConfig } from './oidc-config'; import { OidcService } from './oidc-service'; import { @@ -31,29 +28,7 @@ import { } from './runtime-config'; import { createRefreshTokenStore } from './secure-token-store'; import { StorageGateway } from './storage-gateway'; -import { - apiGetOperationDiagnosticsRequestSchema, - apiInvokeRequestSchema, - authGetSessionSummaryRequestSchema, - authGetTokenDiagnosticsRequestSchema, - authSignInRequestSchema, - authSignOutRequestSchema, - appRuntimeVersionsRequestSchema, - appVersionRequestSchema, - asFailure, - asSuccess, - CONTRACT_VERSION, - handshakeRequestSchema, - IPC_CHANNELS, - openFileDialogRequestSchema, - readTextFileRequestSchema, - storageClearDomainRequestSchema, - storageDeleteRequestSchema, - storageGetRequestSchema, - storageSetRequestSchema, - telemetryTrackRequestSchema, - updatesCheckRequestSchema, -} from '@electron-foundation/contracts'; +import { asFailure } from '@electron-foundation/contracts'; import { toStructuredLogLine } from '@electron-foundation/common'; const { @@ -67,12 +42,6 @@ const navigationPolicy = { isDevelopment, rendererDevUrl }; const fileTokenTtlMs = 5 * 60 * 1000; const fileTokenCleanupIntervalMs = 60 * 1000; -type FileSelectionToken = { - filePath: string; - expiresAt: number; - windowId: number; -}; - const selectedFileTokens = new Map(); let tokenCleanupTimer: NodeJS.Timeout | null = null; let storageGateway: StorageGateway | null = null; @@ -197,544 +166,12 @@ const assertAuthorizedSender = ( return null; }; -const redactTelemetryProperties = ( - properties: Record | undefined, -): Record => { - if (!properties) { - return {}; +const getStorageGateway = () => { + if (!storageGateway) { + throw new Error('Storage gateway is not initialized'); } - const sensitiveKeyPattern = - /token|secret|password|credential|api[-_]?key|auth/i; - const redacted: Record = {}; - - for (const [key, value] of Object.entries(properties)) { - redacted[key] = sensitiveKeyPattern.test(key) ? '[REDACTED]' : value; - } - - return redacted; -}; - -const registerIpcHandlers = () => { - ipcMain.handle(IPC_CHANNELS.handshake, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = handshakeRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return asSuccess({ contractVersion: CONTRACT_VERSION }); - }); - - ipcMain.handle(IPC_CHANNELS.appGetVersion, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = appVersionRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return asSuccess({ version: APP_VERSION }); - }); - - ipcMain.handle(IPC_CHANNELS.appGetRuntimeVersions, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = appRuntimeVersionsRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return asSuccess({ - electron: process.versions.electron, - node: process.versions.node, - chrome: process.versions.chrome, - appEnvironment, - }); - }); - - ipcMain.handle(IPC_CHANNELS.authSignIn, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = authSignInRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - if (!oidcService) { - return asFailure( - 'AUTH/NOT_CONFIGURED', - 'OIDC authentication is not configured for this build.', - undefined, - false, - parsed.data.correlationId, - ); - } - - return oidcService.signIn(); - }); - - ipcMain.handle(IPC_CHANNELS.authSignOut, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = authSignOutRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - if (!oidcService) { - return asSuccess({ signedOut: true }); - } - - return oidcService.signOut(); - }); - - ipcMain.handle(IPC_CHANNELS.authGetSessionSummary, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = authGetSessionSummaryRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - if (!oidcService) { - return asSuccess({ - state: 'signed-out' as const, - scopes: [], - entitlements: [], - }); - } - - return oidcService.getSessionSummary(); - }); - - ipcMain.handle(IPC_CHANNELS.authGetTokenDiagnostics, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = authGetTokenDiagnosticsRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - if (!oidcService) { - return asSuccess({ - sessionState: 'signed-out' as const, - bearerSource: 'access_token' as const, - accessToken: { - present: false, - format: 'absent' as const, - claims: null, - }, - idToken: { - present: false, - format: 'absent' as const, - claims: null, - }, - }); - } - - return oidcService.getTokenDiagnostics(); - }); - - ipcMain.handle(IPC_CHANNELS.dialogOpenFile, async (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = openFileDialogRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - const result = await dialog.showOpenDialog({ - title: parsed.data.payload.title, - filters: parsed.data.payload.filters, - properties: ['openFile'], - }); - - const selectedPath = result.filePaths[0]; - if (result.canceled || !selectedPath) { - return asSuccess({ canceled: true }); - } - - const windowId = BrowserWindow.fromWebContents(event.sender)?.id; - if (!windowId) { - return asFailure( - 'FS/TOKEN_CREATION_FAILED', - 'Unable to associate selected file with a window context.', - undefined, - false, - parsed.data.correlationId, - ); - } - - const fileToken = randomUUID(); - selectedFileTokens.set(fileToken, { - filePath: selectedPath, - windowId, - expiresAt: Date.now() + fileTokenTtlMs, - }); - - return asSuccess({ - canceled: false, - fileName: path.basename(selectedPath), - fileToken, - }); - }); - - ipcMain.handle(IPC_CHANNELS.fsReadTextFile, async (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = readTextFileRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - try { - const selected = selectedFileTokens.get(parsed.data.payload.fileToken); - if (!selected || selected.expiresAt <= Date.now()) { - selectedFileTokens.delete(parsed.data.payload.fileToken); - return asFailure( - 'FS/INVALID_TOKEN', - 'The selected file token is invalid or expired.', - undefined, - false, - parsed.data.correlationId, - ); - } - - const senderWindowId = BrowserWindow.fromWebContents(event.sender)?.id; - if (senderWindowId !== selected.windowId) { - selectedFileTokens.delete(parsed.data.payload.fileToken); - return asFailure( - 'FS/INVALID_TOKEN_SCOPE', - 'Selected file token was issued for a different window.', - { - senderWindowId: senderWindowId ?? null, - tokenWindowId: selected.windowId, - }, - false, - parsed.data.correlationId, - ); - } - - // Tokens are single-use to reduce replay risk from compromised renderers. - selectedFileTokens.delete(parsed.data.payload.fileToken); - - const content = await fs.readFile(selected.filePath, { - encoding: parsed.data.payload.encoding, - }); - - return asSuccess({ content }); - } catch (error) { - return asFailure( - 'FS/READ_FAILED', - 'Unable to read requested file.', - error, - false, - parsed.data.correlationId, - ); - } - }); - - ipcMain.handle(IPC_CHANNELS.apiInvoke, async (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = apiInvokeRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - return invokeApiOperation(parsed.data); - }); - - ipcMain.handle( - IPC_CHANNELS.apiGetOperationDiagnostics, - async (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = apiGetOperationDiagnosticsRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return getApiOperationDiagnostics(parsed.data.payload.operationId); - }, - ); - - ipcMain.handle(IPC_CHANNELS.storageSetItem, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = storageSetRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return storageGateway!.setItem(parsed.data); - }); - - ipcMain.handle(IPC_CHANNELS.storageGetItem, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = storageGetRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return storageGateway!.getItem(parsed.data); - }); - - ipcMain.handle(IPC_CHANNELS.storageDeleteItem, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = storageDeleteRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return storageGateway!.deleteItem(parsed.data); - }); - - ipcMain.handle(IPC_CHANNELS.storageClearDomain, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = storageClearDomainRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - return storageGateway!.clearDomain(parsed.data); - }); - - ipcMain.handle(IPC_CHANNELS.updatesCheck, async (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = updatesCheckRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - try { - const updateConfigPath = path.join( - process.resourcesPath, - '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 updateCheck = await autoUpdater.checkForUpdates(); - const candidateVersion = updateCheck?.updateInfo?.version; - const currentVersion = app.getVersion(); - const hasUpdate = - typeof candidateVersion === 'string' && - candidateVersion.length > 0 && - candidateVersion !== currentVersion; - - if (hasUpdate) { - return asSuccess({ - status: 'available' as const, - message: `Update ${candidateVersion} is available.`, - }); - } - - return asSuccess({ status: 'not-available' as const }); - } catch (error) { - return asSuccess({ - status: 'error' as const, - message: - error instanceof Error ? error.message : 'Update check failed.', - }); - } - }); - - ipcMain.handle(IPC_CHANNELS.telemetryTrack, (event, payload) => { - const correlationId = getCorrelationId(payload); - const unauthorized = assertAuthorizedSender(event, correlationId); - if (unauthorized) { - return unauthorized; - } - - const parsed = telemetryTrackRequestSchema.safeParse(payload); - if (!parsed.success) { - return asFailure( - 'IPC/VALIDATION_FAILED', - 'IPC payload failed validation.', - parsed.error.flatten(), - false, - correlationId, - ); - } - - logEvent('info', 'telemetry.track', parsed.data.correlationId, { - eventName: parsed.data.payload.eventName, - properties: redactTelemetryProperties(parsed.data.payload.properties), - }); - - return asSuccess({ accepted: true }); - }); + return storageGateway; }; const bootstrap = async () => { @@ -767,6 +204,7 @@ const bootstrap = async () => { ? (cipherText) => safeStorage.decryptString(cipherText) : undefined, }); + const oidcConfig = loadOidcConfig(); if (oidcConfig) { const refreshTokenStore = await createRefreshTokenStore({ @@ -792,13 +230,28 @@ const bootstrap = async () => { logEvent(level, event, undefined, details); }, }); + setOidcAccessTokenResolver(() => oidcService?.getApiBearerToken() ?? null); } else { logEvent('info', 'auth.not_configured'); setOidcAccessTokenResolver(null); } - registerIpcHandlers(); + registerIpcHandlers(ipcMain, { + appVersion: APP_VERSION, + appEnvironment, + fileTokenTtlMs, + selectedFileTokens, + getCorrelationId, + assertAuthorizedSender, + getOidcService: () => oidcService, + getStorageGateway, + invokeApiOperation: (request) => invokeApiOperation(request), + getApiOperationDiagnostics: (operationId) => + getApiOperationDiagnostics(operationId), + logEvent, + }); + mainWindow = await createMainWindow(); logEvent('info', 'app.bootstrapped'); diff --git a/apps/desktop-preload/src/api/app-api.ts b/apps/desktop-preload/src/api/app-api.ts new file mode 100644 index 0000000..6546382 --- /dev/null +++ b/apps/desktop-preload/src/api/app-api.ts @@ -0,0 +1,63 @@ +import type { DesktopAppApi } from '@electron-foundation/desktop-api'; +import { + appRuntimeVersionsRequestSchema, + appRuntimeVersionsResponseSchema, + appVersionRequestSchema, + appVersionResponseSchema, + CONTRACT_VERSION, + handshakeRequestSchema, + handshakeResponseSchema, + IPC_CHANNELS, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc, mapResult } from '../invoke-client'; + +export const createAppApi = (): DesktopAppApi => ({ + async getContractVersion() { + const correlationId = createCorrelationId(); + const request = handshakeRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + const result = await invokeIpc( + IPC_CHANNELS.handshake, + request, + correlationId, + handshakeResponseSchema, + ); + + return mapResult(result, (value) => value.contractVersion); + }, + + async getVersion() { + const correlationId = createCorrelationId(); + const request = appVersionRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + const result = await invokeIpc( + IPC_CHANNELS.appGetVersion, + request, + correlationId, + appVersionResponseSchema, + ); + + return mapResult(result, (value) => value.version); + }, + + async getRuntimeVersions() { + const correlationId = createCorrelationId(); + const request = appRuntimeVersionsRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + return invokeIpc( + IPC_CHANNELS.appGetRuntimeVersions, + request, + correlationId, + appRuntimeVersionsResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/api/auth-api.ts b/apps/desktop-preload/src/api/auth-api.ts new file mode 100644 index 0000000..d8c2a23 --- /dev/null +++ b/apps/desktop-preload/src/api/auth-api.ts @@ -0,0 +1,83 @@ +import type { DesktopAuthApi } from '@electron-foundation/desktop-api'; +import { + authGetSessionSummaryRequestSchema, + authGetSessionSummaryResponseSchema, + authGetTokenDiagnosticsRequestSchema, + authGetTokenDiagnosticsResponseSchema, + authSignInRequestSchema, + authSignInResponseSchema, + authSignOutRequestSchema, + authSignOutResponseSchema, + CONTRACT_VERSION, + IPC_CHANNELS, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +const authSignInTimeoutMs = 5 * 60_000; + +export const createAuthApi = (): DesktopAuthApi => ({ + async signIn() { + const correlationId = createCorrelationId(); + const request = authSignInRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.authSignIn, + request, + correlationId, + authSignInResponseSchema, + authSignInTimeoutMs, + ); + }, + + async signOut() { + const correlationId = createCorrelationId(); + const request = authSignOutRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.authSignOut, + request, + correlationId, + authSignOutResponseSchema, + ); + }, + + async getSessionSummary() { + const correlationId = createCorrelationId(); + const request = authGetSessionSummaryRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.authGetSessionSummary, + request, + correlationId, + authGetSessionSummaryResponseSchema, + ); + }, + + async getTokenDiagnostics() { + const correlationId = createCorrelationId(); + const request = authGetTokenDiagnosticsRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.authGetTokenDiagnostics, + request, + correlationId, + authGetTokenDiagnosticsResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/api/dialog-api.ts b/apps/desktop-preload/src/api/dialog-api.ts new file mode 100644 index 0000000..dd2bd09 --- /dev/null +++ b/apps/desktop-preload/src/api/dialog-api.ts @@ -0,0 +1,26 @@ +import type { DesktopDialogApi } from '@electron-foundation/desktop-api'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + openFileDialogRequestSchema, + openFileDialogResponseSchema, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +export const createDialogApi = (): DesktopDialogApi => ({ + async openFile(request = {}) { + const correlationId = createCorrelationId(); + const payload = openFileDialogRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: request, + }); + + return invokeIpc( + IPC_CHANNELS.dialogOpenFile, + payload, + correlationId, + openFileDialogResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/api/external-api.ts b/apps/desktop-preload/src/api/external-api.ts new file mode 100644 index 0000000..ccbc471 --- /dev/null +++ b/apps/desktop-preload/src/api/external-api.ts @@ -0,0 +1,50 @@ +import type { DesktopExternalApi } from '@electron-foundation/desktop-api'; +import { + apiGetOperationDiagnosticsRequestSchema, + apiGetOperationDiagnosticsResponseSchema, + apiInvokeRequestSchema, + apiInvokeResponseSchema, + CONTRACT_VERSION, + IPC_CHANNELS, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +export const createExternalApi = (): DesktopExternalApi => ({ + async invoke(operationId, params, options) { + const correlationId = createCorrelationId(); + const request = apiInvokeRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { + operationId, + params, + headers: options?.headers, + }, + }); + + return invokeIpc( + IPC_CHANNELS.apiInvoke, + request, + correlationId, + apiInvokeResponseSchema, + ); + }, + + async getOperationDiagnostics(operationId) { + const correlationId = createCorrelationId(); + const request = apiGetOperationDiagnosticsRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { + operationId, + }, + }); + + return invokeIpc( + IPC_CHANNELS.apiGetOperationDiagnostics, + request, + correlationId, + apiGetOperationDiagnosticsResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/api/fs-api.ts b/apps/desktop-preload/src/api/fs-api.ts new file mode 100644 index 0000000..d6df25a --- /dev/null +++ b/apps/desktop-preload/src/api/fs-api.ts @@ -0,0 +1,31 @@ +import type { DesktopFsApi } from '@electron-foundation/desktop-api'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + readTextFileRequestSchema, + readTextFileResponseSchema, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc, mapResult } from '../invoke-client'; + +export const createFsApi = (): DesktopFsApi => ({ + async readTextFile(fileToken) { + const correlationId = createCorrelationId(); + const request = readTextFileRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { + fileToken, + encoding: 'utf8', + }, + }); + + const result = await invokeIpc( + IPC_CHANNELS.fsReadTextFile, + request, + correlationId, + readTextFileResponseSchema, + ); + + return mapResult(result, (value) => value.content); + }, +}); diff --git a/apps/desktop-preload/src/api/storage-api.ts b/apps/desktop-preload/src/api/storage-api.ts new file mode 100644 index 0000000..7d6cb3d --- /dev/null +++ b/apps/desktop-preload/src/api/storage-api.ts @@ -0,0 +1,86 @@ +import type { DesktopStorageApi } from '@electron-foundation/desktop-api'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + storageClearDomainRequestSchema, + storageClearDomainResponseSchema, + storageDeleteRequestSchema, + storageDeleteResponseSchema, + storageGetRequestSchema, + storageGetResponseSchema, + storageSetRequestSchema, + storageSetResponseSchema, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +export const createStorageApi = (): DesktopStorageApi => ({ + async setItem(domain, key, value, classification = 'internal', options = {}) { + const correlationId = createCorrelationId(); + const request = storageSetRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { + domain, + key, + value, + classification, + ttlSeconds: options.ttlSeconds, + }, + }); + + return invokeIpc( + IPC_CHANNELS.storageSetItem, + request, + correlationId, + storageSetResponseSchema, + ); + }, + + async getItem(domain, key) { + const correlationId = createCorrelationId(); + const request = storageGetRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { domain, key }, + }); + + return invokeIpc( + IPC_CHANNELS.storageGetItem, + request, + correlationId, + storageGetResponseSchema, + ); + }, + + async deleteItem(domain, key) { + const correlationId = createCorrelationId(); + const request = storageDeleteRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { domain, key }, + }); + + return invokeIpc( + IPC_CHANNELS.storageDeleteItem, + request, + correlationId, + storageDeleteResponseSchema, + ); + }, + + async clearDomain(domain) { + const correlationId = createCorrelationId(); + const request = storageClearDomainRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { domain }, + }); + + return invokeIpc( + IPC_CHANNELS.storageClearDomain, + request, + correlationId, + storageClearDomainResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/api/telemetry-api.ts b/apps/desktop-preload/src/api/telemetry-api.ts new file mode 100644 index 0000000..610a591 --- /dev/null +++ b/apps/desktop-preload/src/api/telemetry-api.ts @@ -0,0 +1,29 @@ +import type { DesktopTelemetryApi } from '@electron-foundation/desktop-api'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + telemetryTrackRequestSchema, + telemetryTrackResponseSchema, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +export const createTelemetryApi = (): DesktopTelemetryApi => ({ + async track(eventName, properties) { + const correlationId = createCorrelationId(); + const request = telemetryTrackRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { + eventName, + properties, + }, + }); + + return invokeIpc( + IPC_CHANNELS.telemetryTrack, + request, + correlationId, + telemetryTrackResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/api/updates-api.ts b/apps/desktop-preload/src/api/updates-api.ts new file mode 100644 index 0000000..e9ad6c0 --- /dev/null +++ b/apps/desktop-preload/src/api/updates-api.ts @@ -0,0 +1,25 @@ +import type { DesktopUpdatesApi } from '@electron-foundation/desktop-api'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + updatesCheckRequestSchema, + updatesCheckResponseSchema, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +export const createUpdatesApi = (): DesktopUpdatesApi => ({ + async check() { + const correlationId = createCorrelationId(); + const request = updatesCheckRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + return invokeIpc( + IPC_CHANNELS.updatesCheck, + request, + correlationId, + updatesCheckResponseSchema, + ); + }, +}); diff --git a/apps/desktop-preload/src/invoke-client.ts b/apps/desktop-preload/src/invoke-client.ts new file mode 100644 index 0000000..84cbba2 --- /dev/null +++ b/apps/desktop-preload/src/invoke-client.ts @@ -0,0 +1,133 @@ +import { ipcRenderer } from 'electron'; +import { z, type ZodType } from 'zod'; +import { toStructuredLogLine } from '@electron-foundation/common'; +import { + asFailure, + asSuccess, + CONTRACT_VERSION, + type DesktopResult, + type IpcChannel, +} from '@electron-foundation/contracts'; + +const ipcInvokeTimeoutMs = 10_000; + +const logPreloadError = ( + event: string, + correlationId: string, + details?: Record, +) => { + const line = toStructuredLogLine({ + level: 'error', + component: 'desktop-preload', + event, + version: CONTRACT_VERSION, + correlationId, + details, + }); + console.error(line); +}; + +const resultSchema = (payloadSchema: ZodType) => + z.union([ + z.object({ + ok: z.literal(true), + data: payloadSchema, + }), + z.object({ + ok: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + retryable: z.boolean(), + correlationId: z.string().optional(), + }), + }), + ]); + +export const createCorrelationId = (): string => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex + .slice(6, 8) + .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +}; + +export const invokeIpc = async ( + channel: IpcChannel, + request: unknown, + correlationId: string, + responsePayloadSchema: ZodType, + timeoutMs = ipcInvokeTimeoutMs, +): Promise> => { + try { + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('IPC invoke timed out')), timeoutMs); + }); + + const response = await Promise.race([ + ipcRenderer.invoke(channel, request), + timeoutPromise, + ]); + + const parsed = resultSchema(responsePayloadSchema).safeParse(response); + if (!parsed.success) { + logPreloadError('ipc.malformed_response', correlationId, { + channel, + }); + return asFailure( + 'IPC_MALFORMED_RESPONSE', + `Received malformed response from channel: ${channel}`, + parsed.error.flatten(), + false, + correlationId, + ); + } + + return parsed.data; + } catch (error) { + if (error instanceof Error && error.message === 'IPC invoke timed out') { + logPreloadError('ipc.invoke_timeout', correlationId, { + channel, + timeoutMs, + }); + return asFailure( + 'IPC/TIMEOUT', + `IPC invoke timed out for channel: ${channel}`, + { timeoutMs }, + true, + correlationId, + ); + } + + logPreloadError('ipc.invoke_failed', correlationId, { + channel, + message: error instanceof Error ? error.message : String(error), + }); + return asFailure( + 'IPC_INVOKE_FAILED', + `IPC invoke failed for channel: ${channel}`, + error, + true, + correlationId, + ); + } +}; + +export const mapResult = ( + result: DesktopResult, + mapper: (value: TFrom) => TTo, +): DesktopResult => { + if (result.ok === false) { + return result as DesktopResult; + } + + return asSuccess(mapper(result.data)); +}; diff --git a/apps/desktop-preload/src/main.ts b/apps/desktop-preload/src/main.ts index 7f7b31a..e220316 100644 --- a/apps/desktop-preload/src/main.ts +++ b/apps/desktop-preload/src/main.ts @@ -1,473 +1,23 @@ -import { contextBridge, ipcRenderer } from 'electron'; -import { z, type ZodType } from 'zod'; +import { contextBridge } from 'electron'; import type { DesktopApi } from '@electron-foundation/desktop-api'; -import { toStructuredLogLine } from '@electron-foundation/common'; -import { - apiGetOperationDiagnosticsRequestSchema, - apiGetOperationDiagnosticsResponseSchema, - apiInvokeRequestSchema, - apiInvokeResponseSchema, - appRuntimeVersionsRequestSchema, - appRuntimeVersionsResponseSchema, - authGetSessionSummaryRequestSchema, - authGetSessionSummaryResponseSchema, - authGetTokenDiagnosticsRequestSchema, - authGetTokenDiagnosticsResponseSchema, - authSignInRequestSchema, - authSignInResponseSchema, - authSignOutRequestSchema, - authSignOutResponseSchema, - appVersionRequestSchema, - appVersionResponseSchema, - asFailure, - asSuccess, - CONTRACT_VERSION, - handshakeRequestSchema, - handshakeResponseSchema, - IPC_CHANNELS, - openFileDialogRequestSchema, - openFileDialogResponseSchema, - readTextFileRequestSchema, - readTextFileResponseSchema, - storageClearDomainRequestSchema, - storageClearDomainResponseSchema, - storageDeleteRequestSchema, - storageDeleteResponseSchema, - storageGetRequestSchema, - storageGetResponseSchema, - storageSetRequestSchema, - storageSetResponseSchema, - telemetryTrackRequestSchema, - telemetryTrackResponseSchema, - updatesCheckRequestSchema, - updatesCheckResponseSchema, - type IpcChannel, - type DesktopResult, -} from '@electron-foundation/contracts'; - -const logPreloadError = ( - event: string, - correlationId: string, - details?: Record, -) => { - const line = toStructuredLogLine({ - level: 'error', - component: 'desktop-preload', - event, - version: CONTRACT_VERSION, - correlationId, - details, - }); - console.error(line); -}; - -const ipcInvokeTimeoutMs = 10_000; -const authSignInTimeoutMs = 5 * 60_000; - -const createCorrelationId = (): string => { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - - // RFC4122 v4 fallback using Web Crypto random values. - const bytes = new Uint8Array(16); - globalThis.crypto.getRandomValues(bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')); - return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex - .slice(6, 8) - .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; -}; - -const resultSchema = (payloadSchema: ZodType) => - z.union([ - z.object({ - ok: z.literal(true), - data: payloadSchema, - }), - z.object({ - ok: z.literal(false), - error: z.object({ - code: z.string(), - message: z.string(), - details: z.unknown().optional(), - retryable: z.boolean(), - correlationId: z.string().optional(), - }), - }), - ]); - -const invoke = async ( - channel: IpcChannel, - request: unknown, - correlationId: string, - responsePayloadSchema: ZodType, - timeoutMs = ipcInvokeTimeoutMs, -): Promise> => { - try { - const timeoutPromise = new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error('IPC invoke timed out')), timeoutMs); - }); - const response = await Promise.race([ - ipcRenderer.invoke(channel, request), - timeoutPromise, - ]); - const parsed = resultSchema(responsePayloadSchema).safeParse(response); - - if (!parsed.success) { - logPreloadError('ipc.malformed_response', correlationId, { - channel, - }); - return asFailure( - 'IPC_MALFORMED_RESPONSE', - `Received malformed response from channel: ${channel}`, - parsed.error.flatten(), - false, - correlationId, - ); - } - - return parsed.data; - } catch (error) { - if (error instanceof Error && error.message === 'IPC invoke timed out') { - logPreloadError('ipc.invoke_timeout', correlationId, { - channel, - timeoutMs, - }); - return asFailure( - 'IPC/TIMEOUT', - `IPC invoke timed out for channel: ${channel}`, - { timeoutMs }, - true, - correlationId, - ); - } - - logPreloadError('ipc.invoke_failed', correlationId, { - channel, - message: error instanceof Error ? error.message : String(error), - }); - return asFailure( - 'IPC_INVOKE_FAILED', - `IPC invoke failed for channel: ${channel}`, - error, - true, - correlationId, - ); - } -}; - -const mapResult = ( - result: DesktopResult, - mapper: (value: TFrom) => TTo, -): DesktopResult => { - if (result.ok === false) { - return result as DesktopResult; - } - - return asSuccess(mapper(result.data)); -}; +import { createAppApi } from './api/app-api'; +import { createAuthApi } from './api/auth-api'; +import { createDialogApi } from './api/dialog-api'; +import { createExternalApi } from './api/external-api'; +import { createFsApi } from './api/fs-api'; +import { createStorageApi } from './api/storage-api'; +import { createTelemetryApi } from './api/telemetry-api'; +import { createUpdatesApi } from './api/updates-api'; const desktopApi: DesktopApi = { - app: { - async getContractVersion() { - const correlationId = createCorrelationId(); - const request = handshakeRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - const result = await invoke( - IPC_CHANNELS.handshake, - request, - correlationId, - handshakeResponseSchema, - ); - - return mapResult(result, (value) => value.contractVersion); - }, - async getVersion() { - const correlationId = createCorrelationId(); - const request = appVersionRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - const result = await invoke( - IPC_CHANNELS.appGetVersion, - request, - correlationId, - appVersionResponseSchema, - ); - - return mapResult(result, (value) => value.version); - }, - async getRuntimeVersions() { - const correlationId = createCorrelationId(); - const request = appRuntimeVersionsRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - return invoke( - IPC_CHANNELS.appGetRuntimeVersions, - request, - correlationId, - appRuntimeVersionsResponseSchema, - ); - }, - }, - auth: { - async signIn() { - const correlationId = createCorrelationId(); - const request = authSignInRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - - return invoke( - IPC_CHANNELS.authSignIn, - request, - correlationId, - authSignInResponseSchema, - authSignInTimeoutMs, - ); - }, - async signOut() { - const correlationId = createCorrelationId(); - const request = authSignOutRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - - return invoke( - IPC_CHANNELS.authSignOut, - request, - correlationId, - authSignOutResponseSchema, - ); - }, - async getSessionSummary() { - const correlationId = createCorrelationId(); - const request = authGetSessionSummaryRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - - return invoke( - IPC_CHANNELS.authGetSessionSummary, - request, - correlationId, - authGetSessionSummaryResponseSchema, - ); - }, - async getTokenDiagnostics() { - const correlationId = createCorrelationId(); - const request = authGetTokenDiagnosticsRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - - return invoke( - IPC_CHANNELS.authGetTokenDiagnostics, - request, - correlationId, - authGetTokenDiagnosticsResponseSchema, - ); - }, - }, - dialog: { - async openFile(request = {}) { - const correlationId = createCorrelationId(); - const payload = openFileDialogRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: request, - }); - - return invoke( - IPC_CHANNELS.dialogOpenFile, - payload, - correlationId, - openFileDialogResponseSchema, - ); - }, - }, - fs: { - async readTextFile(fileToken) { - const correlationId = createCorrelationId(); - const request = readTextFileRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { - fileToken, - encoding: 'utf8', - }, - }); - - const result = await invoke( - IPC_CHANNELS.fsReadTextFile, - request, - correlationId, - readTextFileResponseSchema, - ); - - return mapResult(result, (value) => value.content); - }, - }, - storage: { - async setItem( - domain, - key, - value, - classification = 'internal', - options = {}, - ) { - const correlationId = createCorrelationId(); - const request = storageSetRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { - domain, - key, - value, - classification, - ttlSeconds: options.ttlSeconds, - }, - }); - - return invoke( - IPC_CHANNELS.storageSetItem, - request, - correlationId, - storageSetResponseSchema, - ); - }, - async getItem(domain, key) { - const correlationId = createCorrelationId(); - const request = storageGetRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { domain, key }, - }); - - return invoke( - IPC_CHANNELS.storageGetItem, - request, - correlationId, - storageGetResponseSchema, - ); - }, - async deleteItem(domain, key) { - const correlationId = createCorrelationId(); - const request = storageDeleteRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { domain, key }, - }); - - return invoke( - IPC_CHANNELS.storageDeleteItem, - request, - correlationId, - storageDeleteResponseSchema, - ); - }, - async clearDomain(domain) { - const correlationId = createCorrelationId(); - const request = storageClearDomainRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { domain }, - }); - - return invoke( - IPC_CHANNELS.storageClearDomain, - request, - correlationId, - storageClearDomainResponseSchema, - ); - }, - }, - api: { - async invoke(operationId, params, options) { - const correlationId = createCorrelationId(); - const request = apiInvokeRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { - operationId, - params, - headers: options?.headers, - }, - }); - - return invoke( - IPC_CHANNELS.apiInvoke, - request, - correlationId, - apiInvokeResponseSchema, - ); - }, - async getOperationDiagnostics(operationId) { - const correlationId = createCorrelationId(); - const request = apiGetOperationDiagnosticsRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { - operationId, - }, - }); - - return invoke( - IPC_CHANNELS.apiGetOperationDiagnostics, - request, - correlationId, - apiGetOperationDiagnosticsResponseSchema, - ); - }, - }, - updates: { - async check() { - const correlationId = createCorrelationId(); - const request = updatesCheckRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: {}, - }); - return invoke( - IPC_CHANNELS.updatesCheck, - request, - correlationId, - updatesCheckResponseSchema, - ); - }, - }, - telemetry: { - async track(eventName, properties) { - const correlationId = createCorrelationId(); - const request = telemetryTrackRequestSchema.parse({ - contractVersion: CONTRACT_VERSION, - correlationId, - payload: { - eventName, - properties, - }, - }); - - return invoke( - IPC_CHANNELS.telemetryTrack, - request, - correlationId, - telemetryTrackResponseSchema, - ); - }, - }, + app: createAppApi(), + auth: createAuthApi(), + dialog: createDialogApi(), + fs: createFsApi(), + storage: createStorageApi(), + api: createExternalApi(), + updates: createUpdatesApi(), + telemetry: createTelemetryApi(), }; contextBridge.exposeInMainWorld('desktop', desktopApi); diff --git a/apps/renderer/src/app/app.ts b/apps/renderer/src/app/app.ts index 677a4bf..76e2139 100644 --- a/apps/renderer/src/app/app.ts +++ b/apps/renderer/src/app/app.ts @@ -16,6 +16,7 @@ import { MatIconModule } from '@angular/material/icon'; 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'; type NavLink = { path: string; @@ -46,6 +47,7 @@ const LABS_MODE_STORAGE_KEY = 'angulectron.labsMode'; 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 }, { @@ -162,6 +164,7 @@ export class App { constructor() { void this.initializeLabsModePolicy(); + void this.authSessionState.ensureInitialized(); this.breakpointObserver .observe('(max-width: 1023px)') diff --git a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html index 49dbc08..10b6320 100644 --- a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html +++ b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html @@ -16,7 +16,7 @@ mat-flat-button type="button" (click)="refreshSummary()" - [disabled]="refreshPending() || signOutPending()" + [disabled]="!sessionInitialized() || refreshPending() || signOutPending()" > Refresh Summary @@ -24,7 +24,7 @@ mat-flat-button type="button" (click)="signIn()" - [disabled]="!desktopAvailable() || isActive() || signInPending() || browserFlowPending() || refreshPending() || signOutPending()" + [disabled]="!desktopAvailable() || !sessionInitialized() || isActive() || signInPending() || browserFlowPending() || refreshPending() || signOutPending()" > Sign In @@ -42,7 +42,7 @@ mat-stroked-button type="button" (click)="signOut()" - [disabled]="signOutPending() || !desktopAvailable()" + [disabled]="!sessionInitialized() || !isActive() || signOutPending() || !desktopAvailable()" > Sign Out diff --git a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts index 461c59f..4ebd950 100644 --- a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts +++ b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts @@ -17,6 +17,7 @@ import type { AuthSessionSummary, } from '@electron-foundation/contracts'; import { getDesktopApi } from '@electron-foundation/desktop-api'; +import { AuthSessionStateService } from '../../services/auth-session-state.service'; type StatusTone = 'info' | 'success' | 'warn' | 'error'; const signInUiPendingMaxMs = 2_000; @@ -37,18 +38,18 @@ const signInUiPendingMaxMs = 2_000; export class AuthSessionLabPage { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly authSessionState = inject(AuthSessionStateService); readonly showTokenDiagnostics = signal(isDevMode()); readonly desktopAvailable = signal(!!getDesktopApi()); + readonly sessionInitialized = this.authSessionState.initialized; readonly signInPending = signal(false); readonly browserFlowPending = signal(false); - readonly refreshPending = signal(false); + readonly refreshPending = this.authSessionState.refreshPending; readonly signOutPending = signal(false); readonly statusText = signal('Idle.'); readonly statusTone = signal('info'); - readonly summary = signal(null); - readonly tokenDiagnostics = signal( - null, - ); + readonly summary = this.authSessionState.summary; + readonly tokenDiagnostics = this.authSessionState.tokenDiagnostics; readonly tokenDiagnosticsJson = computed(() => { const diagnostics = this.tokenDiagnostics(); if (!diagnostics) { @@ -57,7 +58,7 @@ export class AuthSessionLabPage { return JSON.stringify(diagnostics, null, 2); }); - readonly isActive = computed(() => this.summary()?.state === 'active'); + readonly isActive = this.authSessionState.isActive; readonly returnUrl = signal('/'); readonly scopes = computed(() => this.summary()?.scopes ?? []); readonly entitlements = computed(() => this.summary()?.entitlements ?? []); @@ -65,43 +66,23 @@ export class AuthSessionLabPage { constructor() { const queryReturnUrl = this.route.snapshot.queryParamMap.get('returnUrl'); this.returnUrl.set(this.toSafeInternalUrl(queryReturnUrl)); + this.statusText.set('Loading session state...'); + void this.initializeSessionState(); } async refreshSummary() { - const desktop = getDesktopApi(); - if (!desktop) { - this.statusText.set('Desktop bridge unavailable in browser mode.'); - this.statusTone.set('warn'); - return; - } - - this.refreshPending.set(true); - try { - const [summaryResult, diagnosticsResult] = await Promise.all([ - desktop.auth.getSessionSummary(), - this.showTokenDiagnostics() - ? desktop.auth.getTokenDiagnostics() - : Promise.resolve(null), - ]); - if (!summaryResult.ok) { - this.statusText.set(summaryResult.error.message); - this.statusTone.set('error'); - return; - } - - this.summary.set(summaryResult.data); - if (diagnosticsResult && diagnosticsResult.ok) { - this.tokenDiagnostics.set(diagnosticsResult.data); - } else { - this.tokenDiagnostics.set(null); - } - this.statusText.set(`Session state: ${summaryResult.data.state}`); + const result = await this.authSessionState.refreshSummary( + this.showTokenDiagnostics(), + ); + if (!result.ok) { + this.statusText.set(result.error.message); this.statusTone.set( - summaryResult.data.state === 'active' ? 'success' : 'info', + result.error.code === 'DESKTOP/UNAVAILABLE' ? 'warn' : 'error', ); - } finally { - this.refreshPending.set(false); + return; } + + this.applySummaryStatus(result.data.state); } async signIn() { @@ -190,6 +171,11 @@ export class AuthSessionLabPage { this.statusTone.set('warn'); return; } + if (!this.isActive()) { + this.statusText.set('No active session to sign out.'); + this.statusTone.set('info'); + return; + } this.signOutPending.set(true); try { @@ -219,4 +205,22 @@ export class AuthSessionLabPage { return url; } + + private async initializeSessionState() { + await this.authSessionState.ensureInitialized(this.showTokenDiagnostics()); + await this.refreshSummary(); + const summary = this.summary(); + if (!summary) { + this.statusText.set('Desktop bridge unavailable in browser mode.'); + this.statusTone.set('warn'); + return; + } + + this.applySummaryStatus(summary.state); + } + + private applySummaryStatus(state: AuthSessionSummary['state']) { + this.statusText.set(`Session state: ${state}`); + this.statusTone.set(state === 'active' ? 'success' : 'info'); + } } diff --git a/apps/renderer/src/app/guards/jwt-route.guards.ts b/apps/renderer/src/app/guards/jwt-route.guards.ts index a36b6db..23f72c8 100644 --- a/apps/renderer/src/app/guards/jwt-route.guards.ts +++ b/apps/renderer/src/app/guards/jwt-route.guards.ts @@ -11,7 +11,7 @@ import { type UrlSegment, type UrlTree, } from '@angular/router'; -import { getDesktopApi } from '@electron-foundation/desktop-api'; +import { AuthSessionStateService } from '../services/auth-session-state.service'; export interface JwtProtectedRouteComponent { canDeactivateJwt?: () => boolean | Promise; @@ -26,12 +26,8 @@ const ensureActiveJwtSession = async ( returnUrl: string, ): Promise => { const router = inject(Router); - const desktop = getDesktopApi(); - if (!desktop) { - return buildAuthUrlTree(router, returnUrl); - } - - const response = await desktop.auth.getSessionSummary(); + const authSessionState = inject(AuthSessionStateService); + const response = await authSessionState.refreshSummary(); if (!response.ok) { return buildAuthUrlTree(router, returnUrl); } diff --git a/apps/renderer/src/app/services/auth-session-state.service.ts b/apps/renderer/src/app/services/auth-session-state.service.ts new file mode 100644 index 0000000..38686d9 --- /dev/null +++ b/apps/renderer/src/app/services/auth-session-state.service.ts @@ -0,0 +1,85 @@ +import { Injectable, computed, signal } from '@angular/core'; +import type { + AuthGetTokenDiagnosticsResponse, + AuthSessionSummary, + DesktopResult, +} from '@electron-foundation/contracts'; +import { getDesktopApi } from '@electron-foundation/desktop-api'; + +@Injectable({ providedIn: 'root' }) +export class AuthSessionStateService { + readonly initialized = signal(false); + readonly refreshPending = signal(false); + readonly summary = signal(null); + readonly tokenDiagnostics = signal( + null, + ); + readonly isActive = computed(() => this.summary()?.state === 'active'); + + private initializationPromise: Promise | null = null; + + async ensureInitialized(includeTokenDiagnostics = false): Promise { + if (this.initialized()) { + return; + } + + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = this.refreshSummary(includeTokenDiagnostics) + .then(() => { + this.initialized.set(true); + }) + .finally(() => { + this.initializationPromise = null; + }); + + return this.initializationPromise; + } + + async refreshSummary( + includeTokenDiagnostics = false, + ): Promise> { + const desktop = getDesktopApi(); + if (!desktop) { + this.summary.set(null); + this.tokenDiagnostics.set(null); + return { + ok: false, + error: { + code: 'DESKTOP/UNAVAILABLE', + message: 'Desktop bridge unavailable in browser mode.', + retryable: false, + }, + }; + } + + this.refreshPending.set(true); + try { + const [summaryResult, diagnosticsResult] = await Promise.all([ + desktop.auth.getSessionSummary(), + includeTokenDiagnostics + ? desktop.auth.getTokenDiagnostics() + : Promise.resolve(null), + ]); + + if (!summaryResult.ok) { + this.summary.set(null); + this.tokenDiagnostics.set(null); + return summaryResult; + } + + this.summary.set(summaryResult.data); + if (diagnosticsResult && diagnosticsResult.ok) { + this.tokenDiagnostics.set(diagnosticsResult.data); + } else if (includeTokenDiagnostics) { + this.tokenDiagnostics.set(null); + } + + return summaryResult; + } finally { + this.refreshPending.set(false); + } + } +}