From a54ef209dfb5e7a568a10b0463e2a5b32f585492 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 5 Sep 2026 00:54:43 +0800 Subject: [PATCH] refactor(desktop): extract App Update feature scope Generated-by: OpenAI Codex # Conflicts: # apps/desktop/renderer-architecture.json # apps/desktop/src/renderer/app-shell.tsx # apps/desktop/src/renderer/settings/about-settings-page.tsx # apps/desktop/src/renderer/settings/about-update-status.ts # docs/astryx-surface-file-inventory.md # docs/astryx-surface-file-inventory.paths --- apps/desktop/renderer-architecture.json | 64 +-- .../__tests__/about-update-status.test.ts | 6 +- .../__tests__/app-update-boundary.test.ts | 485 ++++++++++++++++++ .../__tests__/app-update-controller.test.ts | 160 ++++++ .../main/__tests__/app-update-install.test.ts | 8 +- .../app-update-provider-scope.test.ts | 259 ++++++++++ .../app-update-services-adapter.test.ts | 40 ++ apps/desktop/src/main/app-update-service.ts | 58 +-- apps/desktop/src/preload/bridge-contract.d.ts | 54 +- .../src/renderer/app-shell-app-update.ts | 51 -- apps/desktop/src/renderer/app-shell.tsx | 114 +--- .../composition/desktop-feature-services.tsx | 43 +- .../renderer/features/app-update/README.md | 52 ++ .../controller/use-app-update-controller.ts | 129 +++++ .../src/renderer/features/app-update/index.ts | 25 + .../app-update/locales/app-update-copy.ts | 81 +++ .../app-update/model/about-status-detail.ts | 69 +++ .../app-update/model/install-update.ts} | 15 +- .../app-update/model/update-reminder.ts | 38 ++ .../src/renderer/features/app-update/ports.ts | 45 ++ .../features/app-update/services-context.tsx | 29 ++ .../renderer/features/app-update/testing.ts | 56 ++ .../ui/app-update-projection-context.ts | 44 ++ .../app-update/ui/app-update-provider.tsx | 149 ++++++ .../ui/session-navigation-provider.tsx | 5 - .../src/renderer/locales/shell-copy.ts | 36 -- .../desktop/create-app-update-services.ts | 32 ++ .../renderer/settings/about-settings-page.tsx | 164 +++--- .../renderer/settings/about-update-status.ts | 38 +- apps/desktop/src/shared/app-update.d.ts | 65 +++ .../settings/settings-pages.stories.tsx | 57 +- docs/astryx-surface-file-inventory.md | 4 +- docs/astryx-surface-file-inventory.paths | 2 + .../ui/src/__tests__/sidebar-footer.test.tsx | 16 +- packages/ui/src/components.tsx | 5 + packages/ui/src/session-rail-context.tsx | 12 +- packages/ui/src/session-sidebar-nav.tsx | 12 +- .../src/sidebar-update-projection-context.ts | 43 ++ packages/ui/stories/session-rail-harness.tsx | 24 +- scripts/check-app-shell-hooks.mjs | 4 +- 40 files changed, 2100 insertions(+), 493 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/app-update-boundary.test.ts create mode 100644 apps/desktop/src/main/__tests__/app-update-controller.test.ts create mode 100644 apps/desktop/src/main/__tests__/app-update-provider-scope.test.ts create mode 100644 apps/desktop/src/main/__tests__/app-update-services-adapter.test.ts delete mode 100644 apps/desktop/src/renderer/app-shell-app-update.ts create mode 100644 apps/desktop/src/renderer/features/app-update/README.md create mode 100644 apps/desktop/src/renderer/features/app-update/controller/use-app-update-controller.ts create mode 100644 apps/desktop/src/renderer/features/app-update/index.ts create mode 100644 apps/desktop/src/renderer/features/app-update/locales/app-update-copy.ts create mode 100644 apps/desktop/src/renderer/features/app-update/model/about-status-detail.ts rename apps/desktop/src/renderer/{app-update-install.ts => features/app-update/model/install-update.ts} (85%) create mode 100644 apps/desktop/src/renderer/features/app-update/model/update-reminder.ts create mode 100644 apps/desktop/src/renderer/features/app-update/ports.ts create mode 100644 apps/desktop/src/renderer/features/app-update/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/app-update/testing.ts create mode 100644 apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts create mode 100644 apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-app-update-services.ts create mode 100644 apps/desktop/src/shared/app-update.d.ts create mode 100644 packages/ui/src/sidebar-update-projection-context.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4570aa0895..66756b09f0 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -4,7 +4,6 @@ "src/renderer/agent-graph-panel-visibility.ts", "src/renderer/agent-graph-panel.tsx", "src/renderer/agent-graph-refresh.ts", - "src/renderer/app-shell-app-update.ts", "src/renderer/app-shell-chat-actions.ts", "src/renderer/app-shell-chrome-actions.tsx", "src/renderer/app-shell-command-actions.ts", @@ -23,7 +22,6 @@ "src/renderer/app-shell-turn-actions.ts", "src/renderer/app-shell-turn-view-model.ts", "src/renderer/app-shell.tsx", - "src/renderer/app-update-install.ts", "src/renderer/app.tsx", "src/renderer/astryx-theme-mode.ts", "src/renderer/astryx-theme/maka.js", @@ -288,6 +286,13 @@ "src/renderer/platform/desktop/create-workbar-services.ts -> src/renderer/session-message-settlement" ], "controllerOwners": [ + { + "implementation": "src/renderer/features/app-update/controller/use-app-update-controller.ts", + "symbol": "useAppUpdateController", + "owner": "src/renderer/features/app-update/ui/app-update-provider.tsx", + "ownerSymbol": "AppUpdateProvider", + "count": 1 + }, { "implementation": "src/renderer/features/module-hub/controller/use-module-hub-controller.ts", "symbol": "useModuleHubController", @@ -298,18 +303,6 @@ ], "legacyAppShell": { "files": { - "src/renderer/app-shell-app-update.ts": { - "importDeclarations": 0, - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {}, - "importSpecifiers": 0, - "nonTriviaTokens": 92 - }, "src/renderer/app-shell-chat-actions.ts": { "importDeclarations": 9, "bridgePaths": { @@ -713,12 +706,8 @@ "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 92, + "importDeclarations": 91, "bridgePaths": { - "window.maka.app.installUpdate": 1, - "window.maka.app.retryUpdateDownload": 1, - "window.maka.app.subscribeUpdateStatus": 1, - "window.maka.app.updateStatus": 1, "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, "window.maka.connections.subscribeEvents": 1, @@ -769,13 +758,13 @@ "useAppShellTurnPresentation": 1, "useCommandPalette": 1, "useComposerAttachments": 1, - "useEffect": 14, + "useEffect": 12, "useKeyboardHelp": 1, "useLayoutEffect": 2, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "usePlanModeState": 1, - "useRef": 23, + "useRef": 21, "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, @@ -790,7 +779,7 @@ "useShellRunUpdates": 1, "useShellSearch": 1, "useStableActions": 6, - "useState": 17, + "useState": 16, "useSystemUiLocale": 1, "useTaskEntryController": 1, "useTaskSubmissionReadiness": 1, @@ -804,7 +793,6 @@ "dependencyPaths": { "../preload/transcript-contract.js": 1, "./agent-graph-panel": 1, - "./app-shell-app-update": 1, "./app-shell-chat-actions": 1, "./app-shell-chrome-actions": 1, "./app-shell-context-compaction": 1, @@ -818,7 +806,6 @@ "./app-shell-stop-action": 1, "./app-shell-turn-actions": 1, "./app-shell-turn-view-model": 1, - "./app-update-install": 1, "./chat-composer-region": 1, "./chat-message-surface": 1, "./command-palette": 1, @@ -830,6 +817,7 @@ "./desktop-execution-boundary-surface": 1, "./desktop-slash-command": 1, "./error-boundary": 1, + "./features/app-update/index.js": 1, "./features/conversation": 1, "./features/goals": 1, "./features/module-hub": 1, @@ -895,8 +883,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 147, - "nonTriviaTokens": 15588 + "importSpecifiers": 145, + "nonTriviaTokens": 15057 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1085,15 +1073,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/app-update-install.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/astryx-theme/type-scale.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2250,18 +2229,15 @@ }, "src/renderer/settings/about-settings-page.tsx": { "bridgePaths": { - "window.maka.app.checkForUpdates": 1, "window.maka.app.info": 1, - "window.maka.app.subscribeUpdateStatus": 1, - "window.maka.app.updateStatus": 1, "window.maka.diagnostics.copyReport": 1 }, "environmentCapabilities": {}, "hookCalls": { "useActionGuard": 2, - "useEffect": 2, + "useEffect": 1, "useMountedRef": 1, - "useState": 5, + "useState": 3, "useToast": 1, "useUiLocale": 1 }, @@ -2270,6 +2246,7 @@ "actionFactories": [], "dependencyPaths": { "../default-runtime-host-operation.js": 1, + "../features/app-update/index.js": 1, "../locales/settings-preferences-copy.js": 1, "./about-update-status.js": 1, "./settings-error-copy.js": 1, @@ -4843,13 +4820,6 @@ "src/renderer/app-shell.tsx" ] }, - { - "capability": "app-lifecycle", - "targetZone": "application/app-lifecycle", - "legacyPaths": [ - "src/renderer/app-shell-app-update.ts" - ] - }, { "capability": "task-submission-and-composer-drafts", "targetZone": "features/conversation", diff --git a/apps/desktop/src/main/__tests__/about-update-status.test.ts b/apps/desktop/src/main/__tests__/about-update-status.test.ts index a3e7e70ac2..7c0cf817e6 100644 --- a/apps/desktop/src/main/__tests__/about-update-status.test.ts +++ b/apps/desktop/src/main/__tests__/about-update-status.test.ts @@ -19,10 +19,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { - aboutChannelFacts, - aboutUpdateStatusDetail, -} from '../../renderer/settings/about-update-status.js'; +import { aboutUpdateStatusDetail } from '../../renderer/features/app-update/index.js'; +import { aboutChannelFacts } from '../../renderer/settings/about-update-status.js'; import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js'; const copy = getSettingsPreferencesCopy('zh-CN').about; diff --git a/apps/desktop/src/main/__tests__/app-update-boundary.test.ts b/apps/desktop/src/main/__tests__/app-update-boundary.test.ts new file mode 100644 index 0000000000..067813329d --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-boundary.test.ts @@ -0,0 +1,485 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parse } from '@babel/parser'; +// The architecture checker is an executable JavaScript module by design. +// @ts-expect-error It does not publish a declaration file. +import { analyzeRendererSource } from '../../../scripts/check-renderer-architecture.mjs'; + +const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const rendererRoot = join(desktopRoot, 'src', 'renderer'); +const featureRoot = join(rendererRoot, 'features', 'app-update'); +const uiPackageRoot = resolve(desktopRoot, '..', '..', 'packages', 'ui'); +const uiRoot = join(uiPackageRoot, 'src'); +const sourceCache = new Map(); +const analysisCache = new Map>(); + +function sourceOf(path: string): string { + const cached = sourceCache.get(path); + if (cached !== undefined) return cached; + const source = readFileSync(path, 'utf8'); + sourceCache.set(path, source); + return source; +} + +function analysisOf(path: string): ReturnType { + const cached = analysisCache.get(path); + if (cached) return cached; + const analysis = analyzeRendererSource(sourceOf(path), path); + analysisCache.set(path, analysis); + return analysis; +} + +function sourceFiles(root: string): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(?:ts|tsx|md)$/.test(entry.name) ? [path] : []; + }); +} + +function productionRendererSources(): string[] { + return sourceFiles(rendererRoot).filter((path) => + /\.tsx?$/.test(path) && + !path.replace(/\\/g, '/').includes('/__tests__/') && + !path.endsWith(`${join('', 'testing.ts')}`), + ); +} + +function productionUiSources(): string[] { + return sourceFiles(uiRoot).filter((path) => + /\.tsx?$/.test(path) && + !path.replace(/\\/g, '/').includes('/__tests__/'), + ); +} + +function productSourceLabel(path: string): string { + if (path.startsWith(desktopRoot)) { + return `apps/desktop/${relative(desktopRoot, path).replace(/\\/g, '/')}`; + } + return `packages/ui/${relative(uiPackageRoot, path).replace(/\\/g, '/')}`; +} + +function jsxBindings(source: string, file: string, exportedName: string) { + const ast = parse(source, { + createImportExpressions: true, + sourceType: 'module', + sourceFilename: file, + plugins: ['typescript', 'jsx'], + }); + const imports = new Map(); + const namespaces = new Map(); + const openings: string[] = []; + const namespaceOpenings: Array<{ namespace: string; name: string }> = []; + + function visit(value: unknown): void { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + const node = value as Record; + if (node.type === 'ImportDeclaration') { + const sourceNode = node.source as { value?: unknown } | undefined; + for (const specifier of (node.specifiers as Array> | undefined) ?? []) { + const local = specifier.local as { name?: unknown } | undefined; + if (specifier.type === 'ImportNamespaceSpecifier' && typeof local?.name === 'string') { + namespaces.set(local.name, String(sourceNode?.value ?? '')); + continue; + } + if (specifier.type !== 'ImportSpecifier') continue; + const imported = specifier.imported as { name?: unknown; value?: unknown } | undefined; + if ( + (imported?.name === exportedName || imported?.value === exportedName) && + typeof local?.name === 'string' + ) { + imports.set(local.name, String(sourceNode?.value ?? '')); + } + } + } + if (node.type === 'JSXOpeningElement') { + const name = node.name as Record | undefined; + if (name?.type === 'JSXIdentifier' && typeof name.name === 'string') { + openings.push(name.name); + } + if (name?.type === 'JSXMemberExpression') { + const object = name.object as { type?: unknown; name?: unknown } | undefined; + const property = name.property as { type?: unknown; name?: unknown } | undefined; + if ( + object?.type === 'JSXIdentifier' && typeof object.name === 'string' && + property?.type === 'JSXIdentifier' && typeof property.name === 'string' + ) { + namespaceOpenings.push({ namespace: object.name, name: property.name }); + } + } + } + for (const [key, child] of Object.entries(node)) { + if (key !== 'loc' && key !== 'start' && key !== 'end') visit(child); + } + } + + visit(ast.program); + return [ + ...[...imports.entries()].flatMap(([local, dependency]) => + openings.filter((name) => name === local).map(() => dependency), + ), + ...namespaceOpenings.flatMap((opening) => { + const dependency = namespaces.get(opening.namespace); + return opening.name === exportedName && dependency ? [dependency] : []; + }), + ]; +} + +function moduleEntryBindings( + source: string, + file: string, + matches: (dependency: string) => boolean, +): string[] { + const ast = parse(source, { + createImportExpressions: true, + sourceType: 'module', + sourceFilename: file, + plugins: ['typescript', 'jsx'], + }); + const bindings: string[] = []; + + function importedName(value: { type: string; name?: string; value?: string }): string { + return value.type === 'Identifier' ? String(value.name) : String(value.value); + } + + function visit(value: unknown): void { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + const node = value as Record; + if (node.type === 'ImportDeclaration') { + const dependency = String((node.source as { value?: unknown } | undefined)?.value ?? ''); + if (matches(dependency)) { + for (const specifier of (node.specifiers as Array> | undefined) ?? []) { + if (specifier.type === 'ImportSpecifier') { + bindings.push(importedName(specifier.imported as Parameters[0])); + } else if (specifier.type === 'ImportDefaultSpecifier') { + bindings.push('default'); + } else { + bindings.push('*'); + } + } + } + } + if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') { + const dependency = String((node.source as { value?: unknown } | undefined)?.value ?? ''); + if (matches(dependency)) { + if (node.type === 'ExportAllDeclaration') { + bindings.push('export:*'); + } else { + for (const specifier of (node.specifiers as Array> | undefined) ?? []) { + if (specifier.type === 'ExportSpecifier') { + bindings.push( + `export:${importedName(specifier.local as Parameters[0])}`, + ); + } else { + bindings.push('export:*'); + } + } + } + } + } + if (node.type === 'ImportExpression') { + const dependency = String((node.source as { value?: unknown } | undefined)?.value ?? ''); + if (matches(dependency)) bindings.push('dynamic:*'); + } + for (const [key, child] of Object.entries(node)) { + if (key !== 'loc' && key !== 'start' && key !== 'end') visit(child); + } + } + + visit(ast.program); + return bindings; +} + +function featureEntryImports(source: string, file: string): string[] { + return moduleEntryBindings( + source, + file, + (dependency) => dependency.includes('features/app-update'), + ); +} + +function desktopAppUpdateAdapterBindings(source: string, file: string): string[] { + return moduleEntryBindings( + source, + file, + (dependency) => dependency.includes('platform/desktop/create-app-update-services'), + ); +} + +function sidebarProjectionEntryBindings(source: string, file: string): string[] { + return moduleEntryBindings( + source, + file, + (dependency) => + dependency === '@maka/ui' || + dependency.startsWith('@maka/ui/') || + dependency.includes('sidebar-update-projection-context'), + ).filter((binding) => { + const name = binding.replace(/^(?:export:|dynamic:)/, ''); + return name === '*' || + name === 'SidebarUpdateProjectionProvider' || + name === 'useSidebarUpdateProjection'; + }); +} + +describe('App Update feature boundary', () => { + test('recognizes alias, namespace, re-export, and dynamic-import escape hatches', () => { + const dependency = './features/app-update/index.js'; + assert.deepEqual( + moduleEntryBindings( + [ + `import { AppUpdateProvider as Provider } from '${dependency}';`, + `import * as AppUpdate from '${dependency}';`, + `export { AppUpdateProvider as UpdateOwner } from '${dependency}';`, + `export * from '${dependency}';`, + `void import('${dependency}');`, + ].join('\n'), + 'fixture.tsx', + (candidate) => candidate.includes('features/app-update'), + ), + ['AppUpdateProvider', '*', 'export:AppUpdateProvider', 'export:*', 'dynamic:*'], + ); + assert.deepEqual( + jsxBindings( + [ + `import { AppUpdateProvider as Provider } from '${dependency}';`, + `import * as AppUpdate from '${dependency}';`, + '<>', + ].join('\n'), + 'fixture.tsx', + 'AppUpdateProvider', + ), + [dependency, dependency], + ); + }); + + test('keeps the controller hook exclusively owned by AppUpdateProvider', () => { + const owners: string[] = []; + for (const path of productionRendererSources()) { + const analysis = analysisOf(path); + const calls = analysis.hookCalls.useAppUpdateController ?? 0; + for (let index = 0; index < calls; index += 1) { + owners.push(relative(desktopRoot, path).replace(/\\/g, '/')); + } + } + assert.deepEqual(owners, [ + 'src/renderer/features/app-update/ui/app-update-provider.tsx', + ]); + }); + + test('keeps the services hook exclusively owned by the controller', () => { + const owners: string[] = []; + for (const path of productionRendererSources()) { + const analysis = analysisOf(path); + const calls = analysis.hookCalls.useAppUpdateServices ?? 0; + for (let index = 0; index < calls; index += 1) { + owners.push(relative(desktopRoot, path).replace(/\\/g, '/')); + } + } + assert.deepEqual(owners, [ + 'src/renderer/features/app-update/controller/use-app-update-controller.ts', + ]); + }); + + test('keeps Desktop globals and shell/process dependencies out of the feature', () => { + const violations: string[] = []; + for (const path of sourceFiles(featureRoot)) { + if (!/\.tsx?$/.test(path) || path.endsWith('testing.ts')) continue; + const analysis = analysisOf(path); + for (const capability of Object.keys(analysis.bridgePaths)) { + violations.push(`${relative(desktopRoot, path)}: ${capability}`); + } + for (const dependency of analysis.dependencies as string[]) { + if ( + dependency.includes('app-shell') || + dependency.includes('/preload/') || + dependency.includes('/main/') || + dependency.includes('/settings/') + ) { + violations.push(`${relative(desktopRoot, path)}: ${dependency}`); + } + } + } + assert.deepEqual(violations, []); + }); + + test('allows consumers to enter only through production or testing entries', () => { + const violations: string[] = []; + for (const path of sourceFiles(join(desktopRoot, 'src'))) { + if (path.startsWith(featureRoot) || !/\.tsx?$/.test(path)) continue; + const analysis = analysisOf(path); + for (const dependency of analysis.dependencies as string[]) { + if (!dependency.includes('features/app-update')) continue; + const normalized = dependency.replace(/\\/g, '/'); + const explicitEntry = normalized.endsWith('/features/app-update') + ? `${normalized}/index` + : normalized; + const productionRenderer = path.startsWith(rendererRoot) && + !path.replace(/\\/g, '/').includes('/__tests__/'); + const allowed = productionRenderer + ? /\/features\/app-update\/index(?:\.js)?$/ + : /\/features\/app-update\/(?:index|testing)(?:\.js)?$/; + if (!allowed.test(explicitEntry)) { + violations.push(`${relative(desktopRoot, path)}: ${dependency}`); + } + } + } + assert.deepEqual(violations, []); + }); + + test('pins the production entry surface to its composition and leaf owners', () => { + const imports: string[] = []; + for (const path of productionRendererSources()) { + const source = sourceOf(path); + const owner = relative(desktopRoot, path).replace(/\\/g, '/'); + for (const imported of featureEntryImports(source, path)) { + imports.push(`${owner}: ${imported}`); + } + } + assert.deepEqual(imports.sort(), [ + 'src/renderer/app-shell.tsx: AppUpdateProvider', + 'src/renderer/composition/desktop-feature-services.tsx: AppUpdateServicesProvider', + 'src/renderer/platform/desktop/create-app-update-services.ts: AppUpdateServices', + 'src/renderer/settings/about-settings-page.tsx: AppUpdateAboutProjection', + 'src/renderer/settings/about-settings-page.tsx: AppUpdateAboutProjectionConsumer', + 'src/renderer/settings/about-settings-page.tsx: aboutUpdateStatusDetail', + ]); + }); + + test('keeps the Desktop adapter exclusively owned by feature-services composition', () => { + const bindings: string[] = []; + for (const path of productionRendererSources()) { + const source = sourceOf(path); + const owner = relative(desktopRoot, path).replace(/\\/g, '/'); + for (const binding of desktopAppUpdateAdapterBindings(source, path)) { + bindings.push(`${owner}: ${binding}`); + } + } + assert.deepEqual(bindings, [ + 'src/renderer/composition/desktop-feature-services.tsx: createDesktopAppUpdateServices', + ]); + }); + + test('pins sidebar projection runtime bindings to its provider and footer owners', () => { + const bindings: string[] = []; + for (const path of [...productionRendererSources(), ...productionUiSources()]) { + const source = sourceOf(path); + for (const binding of sidebarProjectionEntryBindings(source, path)) { + bindings.push(`${productSourceLabel(path)}: ${binding}`); + } + } + assert.deepEqual(bindings.sort(), [ + 'apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx: SidebarUpdateProjectionProvider', + 'packages/ui/src/components.tsx: export:SidebarUpdateProjectionProvider', + 'packages/ui/src/components.tsx: export:useSidebarUpdateProjection', + 'packages/ui/src/session-sidebar-nav.tsx: useSidebarUpdateProjection', + ]); + }); + + test('keeps controller and fakes out of the production entry', () => { + const productionEntry = sourceOf(join(featureRoot, 'index.ts')); + assert.equal(productionEntry.includes('useAppUpdateController'), false); + assert.equal(productionEntry.includes('createFakeAppUpdateServices'), false); + assert.equal(productionEntry.includes("from './testing"), false); + }); + + test('keeps raw update capabilities out of every production renderer module', () => { + const updateCapabilities = [ + 'window.maka.app.updateStatus', + 'window.maka.app.subscribeUpdateStatus', + 'window.maka.app.checkForUpdates', + 'window.maka.app.retryUpdateDownload', + 'window.maka.app.installUpdate', + // A computed read from the app namespace could select any update method + // and must not become a back door around the explicit capability list. + 'window.maka.app.*', + ]; + const violations: string[] = []; + for (const path of productionRendererSources()) { + const relativePath = relative(desktopRoot, path).replace(/\\/g, '/'); + const analysis = analysisOf(path); + for (const capability of updateCapabilities) { + if ((analysis.bridgePaths[capability] ?? 0) > 0) { + violations.push(`${relativePath}: ${capability}`); + } + } + } + assert.deepEqual(violations, []); + }); + + test('mounts one public Provider and binds each projection at its narrow leaf', () => { + const providerOwners: string[] = []; + const aboutReaders: string[] = []; + const sidebarProviderOwners: string[] = []; + for (const path of productionRendererSources()) { + const source = sourceOf(path); + for (const dependency of jsxBindings(source, path, 'AppUpdateProvider')) { + if (dependency.includes('features/app-update')) { + providerOwners.push(relative(desktopRoot, path).replace(/\\/g, '/')); + } + } + for (const dependency of jsxBindings(source, path, 'AppUpdateAboutProjectionConsumer')) { + if (dependency.includes('features/app-update')) { + aboutReaders.push(relative(desktopRoot, path).replace(/\\/g, '/')); + } + } + for (const dependency of jsxBindings(source, path, 'SidebarUpdateProjectionProvider')) { + if (dependency === '@maka/ui') { + sidebarProviderOwners.push(productSourceLabel(path)); + } + } + } + for (const path of productionUiSources()) { + const source = sourceOf(path); + for (const dependency of jsxBindings(source, path, 'SidebarUpdateProjectionProvider')) { + if (dependency.includes('sidebar-update-projection-context')) { + sidebarProviderOwners.push(productSourceLabel(path)); + } + } + } + assert.deepEqual(providerOwners, ['src/renderer/app-shell.tsx']); + assert.deepEqual(aboutReaders, ['src/renderer/settings/about-settings-page.tsx']); + assert.deepEqual(sidebarProviderOwners, [ + 'apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx', + ]); + + const sidebarReaders: string[] = []; + for (const path of [...productionRendererSources(), ...productionUiSources()]) { + const analysis = analysisOf(path); + if ((analysis.hookCalls.useSidebarUpdateProjection ?? 0) > 0) { + sidebarReaders.push(productSourceLabel(path)); + } + } + assert.deepEqual(sidebarReaders, ['packages/ui/src/session-sidebar-nav.tsx']); + }); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-controller.test.ts b/apps/desktop/src/main/__tests__/app-update-controller.test.ts new file mode 100644 index 0000000000..d91a68ac9b --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-controller.test.ts @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, describe, test } from 'node:test'; +import { act, createElement } from 'react'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + AppUpdateServicesProvider, + createFakeAppUpdateServices, + useAppUpdateController, + type AppUpdateController, + type AppUpdateStatus, +} from '../../renderer/features/app-update/testing.js'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +let latest: AppUpdateController | undefined; + +function ControllerProbe() { + latest = useAppUpdateController(); + return null; +} + +afterEach(() => { + latest = undefined; + cleanupFakeDom(); +}); + +describe('App Update controller', () => { + test('subscribes before reading and rejects a stale initial snapshot', async () => { + const { root } = installReactRenderer(); + const initial = deferred(); + const order: string[] = []; + let emit: ((status: AppUpdateStatus) => void) | undefined; + let unsubscribeCount = 0; + const services = createFakeAppUpdateServices({ + appUpdate: { + updateStatus: () => { + order.push('read'); + return initial.promise; + }, + checkForUpdates: async () => ({ state: 'idle', currentVersion: '1.0.0' }), + retryUpdateDownload: async () => ({ state: 'idle', currentVersion: '1.0.0' }), + installUpdate: async () => ({ ok: false, reason: 'not_downloaded' }), + subscribeUpdateStatus: (handler) => { + order.push('subscribe'); + emit = handler; + return () => { + unsubscribeCount += 1; + }; + }, + }, + }); + + await act(async () => { + root.render(createElement( + AppUpdateServicesProvider, + { services }, + createElement(ControllerProbe), + )); + }); + assert.deepEqual(order, ['subscribe', 'read']); + + const downloaded: AppUpdateStatus = { + state: 'downloaded', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + }; + await act(async () => emit?.(downloaded)); + await act(async () => { + initial.resolve({ state: 'idle', currentVersion: '1.0.0' }); + await initial.promise; + }); + assert.deepEqual(latest?.status, downloaded); + + await act(async () => root.unmount()); + assert.equal(unsubscribeCount, 1); + emit?.({ state: 'checking', currentVersion: '1.0.0' }); + assert.deepEqual(latest?.status, downloaded); + }); + + test('deduplicates manual checks and fences their returned snapshot behind pushes', async () => { + const { root } = installReactRenderer(); + const check = deferred(); + let checkCount = 0; + let emit: ((status: AppUpdateStatus) => void) | undefined; + const services = createFakeAppUpdateServices({ + appUpdate: { + updateStatus: async () => ({ state: 'idle', currentVersion: '1.0.0' }), + checkForUpdates: () => { + checkCount += 1; + return check.promise; + }, + retryUpdateDownload: async () => ({ state: 'idle', currentVersion: '1.0.0' }), + installUpdate: async () => ({ ok: false, reason: 'not_downloaded' }), + subscribeUpdateStatus: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => { + root.render(createElement( + AppUpdateServicesProvider, + { services }, + createElement(ControllerProbe), + )); + }); + + let first!: Promise; + let second!: Promise; + await act(async () => { + first = latest!.commands.checkForUpdates(); + second = latest!.commands.checkForUpdates(); + }); + assert.equal(first, second); + assert.equal(checkCount, 1); + assert.equal(latest?.checking, true); + + const progress: AppUpdateStatus = { + state: 'downloading', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + progress: { percent: 42 }, + }; + await act(async () => emit?.(progress)); + await act(async () => { + check.resolve({ state: 'checking', currentVersion: '1.0.0' }); + await Promise.all([first, second]); + }); + + assert.deepEqual(latest?.status, progress); + assert.equal(latest?.checking, false); + await act(async () => root.unmount()); + }); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-install.test.ts b/apps/desktop/src/main/__tests__/app-update-install.test.ts index f1467a24b7..d2d3a05bf3 100644 --- a/apps/desktop/src/main/__tests__/app-update-install.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-install.test.ts @@ -19,14 +19,12 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { - AppUpdateInstallRequest, - AppUpdateInstallResult, -} from '../../preload/bridge-contract.js'; import { isAppUpdateInstallFailure, requestDownloadedAppUpdate, -} from '../../renderer/app-update-install.js'; + type AppUpdateInstallRequest, + type AppUpdateInstallResult, +} from '../../renderer/features/app-update/testing.js'; describe('requestDownloadedAppUpdate', () => { test('identifies asynchronous install failures from update status', () => { diff --git a/apps/desktop/src/main/__tests__/app-update-provider-scope.test.ts b/apps/desktop/src/main/__tests__/app-update-provider-scope.test.ts new file mode 100644 index 0000000000..33c4aeb649 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-provider-scope.test.ts @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, describe, test } from 'node:test'; +import { act, createElement, Fragment } from 'react'; +import { + LocaleProvider, + ToastProvider, + useSidebarUpdateProjection, + type SidebarUpdateProjection, +} from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + AppUpdateAboutProjectionConsumer, + AppUpdateProvider, + AppUpdateServicesProvider, + createFakeAppUpdateServices, + type AppUpdateAboutProjection, + type AppUpdateInstallResult, + type AppUpdateServices, + type AppUpdateStatus, +} from '../../renderer/features/app-update/testing.js'; + +let shellRenders = 0; +let unrelatedRenders = 0; +let sidebarRenders = 0; +let aboutRenders = 0; +let latestSidebar: SidebarUpdateProjection | undefined; +let latestAbout: AppUpdateAboutProjection | undefined; + +function UnrelatedProbe() { + unrelatedRenders += 1; + return null; +} + +function SidebarProbe() { + sidebarRenders += 1; + latestSidebar = useSidebarUpdateProjection(); + return null; +} + +function AboutProbe() { + return createElement(AppUpdateAboutProjectionConsumer, { + children: (projection) => { + aboutRenders += 1; + latestAbout = projection; + return null; + }, + }); +} + +function ShellProbe(props: { readonly aboutOpen: boolean }) { + shellRenders += 1; + return createElement( + Fragment, + null, + createElement(UnrelatedProbe), + createElement(SidebarProbe), + props.aboutOpen ? createElement(AboutProbe) : null, + ); +} + +function renderProvider( + root: ReturnType['root'], + services: AppUpdateServices, + aboutOpen: boolean, +) { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement( + ToastProvider, + null, + createElement( + AppUpdateServicesProvider, + { services }, + createElement( + AppUpdateProvider, + null, + createElement(ShellProbe, { aboutOpen }), + ), + ), + ), + })); +} + +function downloading(percent: number): AppUpdateStatus { + return { + state: 'downloading', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + progress: { percent }, + }; +} + +afterEach(() => { + shellRenders = 0; + unrelatedRenders = 0; + sidebarRenders = 0; + aboutRenders = 0; + latestSidebar = undefined; + latestAbout = undefined; + cleanupFakeDom(); +}); + +describe('AppUpdateProvider render scope', () => { + test('routes progress only to an open About reader and reminders only to the footer', async () => { + const { root } = installReactRenderer(); + let emit: ((status: AppUpdateStatus) => void) | undefined; + const defaults = createFakeAppUpdateServices(); + const services = createFakeAppUpdateServices({ + appUpdate: { + ...defaults.appUpdate, + updateStatus: async () => ({ state: 'idle', currentVersion: '1.0.0' }), + subscribeUpdateStatus: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => renderProvider(root, services, true)); + const initial = { + shell: shellRenders, + unrelated: unrelatedRenders, + sidebar: sidebarRenders, + about: aboutRenders, + }; + + await act(async () => emit?.(downloading(10))); + assert.equal(shellRenders, initial.shell); + assert.equal(unrelatedRenders, initial.unrelated); + assert.equal(sidebarRenders, initial.sidebar); + assert.equal(aboutRenders, initial.about + 1); + assert.equal(latestAbout?.status?.state, 'downloading'); + + const afterFirstProgress = aboutRenders; + await act(async () => emit?.(downloading(20))); + assert.equal(shellRenders, initial.shell); + assert.equal(unrelatedRenders, initial.unrelated); + assert.equal(sidebarRenders, initial.sidebar); + assert.equal(aboutRenders, afterFirstProgress + 1); + + await act(async () => emit?.({ + state: 'downloaded', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + })); + assert.equal(shellRenders, initial.shell); + assert.equal(unrelatedRenders, initial.unrelated); + assert.equal(sidebarRenders, initial.sidebar + 1); + assert.equal(aboutRenders, afterFirstProgress + 2); + assert.deepEqual(latestSidebar?.reminder, { + state: 'downloaded', + latestVersion: '1.1.0', + }); + + const afterDownloaded = { + sidebarRenders, + aboutRenders, + projection: latestSidebar, + }; + await act(async () => emit?.({ + state: 'downloaded', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + })); + assert.equal(sidebarRenders, afterDownloaded.sidebarRenders); + assert.equal(aboutRenders, afterDownloaded.aboutRenders + 1); + assert.equal(latestSidebar, afterDownloaded.projection); + + await act(async () => root.unmount()); + }); + + test('has no reader work for progress while About is closed', async () => { + const { root } = installReactRenderer(); + let emit: ((status: AppUpdateStatus) => void) | undefined; + const defaults = createFakeAppUpdateServices(); + const services = createFakeAppUpdateServices({ + appUpdate: { + ...defaults.appUpdate, + updateStatus: async () => ({ state: 'idle', currentVersion: '1.0.0' }), + subscribeUpdateStatus: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => renderProvider(root, services, false)); + const before = [shellRenders, unrelatedRenders, sidebarRenders, aboutRenders]; + await act(async () => emit?.(downloading(73))); + assert.deepEqual( + [shellRenders, unrelatedRenders, sidebarRenders, aboutRenders], + before, + ); + await act(async () => root.unmount()); + }); + + test('deduplicates concurrent install requests at the persistent provider owner', async () => { + const { root } = installReactRenderer(); + let emit: ((status: AppUpdateStatus) => void) | undefined; + let resolveInstall!: (result: AppUpdateInstallResult) => void; + const installResult = new Promise((resolve) => { + resolveInstall = resolve; + }); + let installCount = 0; + const defaults = createFakeAppUpdateServices(); + const services = createFakeAppUpdateServices({ + appUpdate: { + ...defaults.appUpdate, + updateStatus: async () => ({ state: 'idle', currentVersion: '1.0.0' }), + installUpdate: () => { + installCount += 1; + return installResult; + }, + subscribeUpdateStatus: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => renderProvider(root, services, false)); + await act(async () => emit?.({ + state: 'downloaded', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + })); + await act(async () => { + latestSidebar?.onOpenUpdate?.(); + latestSidebar?.onOpenUpdate?.(); + }); + assert.equal(installCount, 1); + + await act(async () => { + resolveInstall({ ok: true }); + await installResult; + await Promise.resolve(); + }); + await act(async () => root.unmount()); + }); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-services-adapter.test.ts b/apps/desktop/src/main/__tests__/app-update-services-adapter.test.ts new file mode 100644 index 0000000000..a0c84779bf --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-services-adapter.test.ts @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createDesktopAppUpdateServices, + type DesktopAppUpdateBridge, +} from '../../renderer/platform/desktop/create-app-update-services.js'; + +test('the Desktop adapter narrows the authoritative app bridge without wrapping it', () => { + const app = { + updateStatus: async () => ({ state: 'idle' as const, currentVersion: '1.0.0' }), + checkForUpdates: async () => ({ state: 'checking' as const, currentVersion: '1.0.0' }), + retryUpdateDownload: async () => ({ state: 'idle' as const, currentVersion: '1.0.0' }), + installUpdate: async () => ({ ok: true as const }), + subscribeUpdateStatus: () => () => undefined, + }; + const bridge = { app } as unknown as DesktopAppUpdateBridge; + + const services = createDesktopAppUpdateServices(bridge); + + assert.equal(services.appUpdate, app); +}); diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index 155bb22dc0..d5d1fed825 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -23,53 +23,19 @@ import type { ProgressInfo, UpdateInfo } from 'electron-updater'; import type { DownloadedUpdateAttestationVerifier } from './app-update-attestation.js'; import type { DesktopUpdateChannel } from './app-update-attestation.js'; import { resolveUpdateFeedOverride } from './app-update-test-context.js'; +import type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateProgress as SharedAppUpdateProgress, + AppUpdateStatus, +} from '../shared/app-update.js'; -export type AppUpdateProgress = { - percent: number; - bytesPerSecond?: number; - transferred?: number; - total?: number; -}; - -export type AppUpdateStatus = - | { state: 'idle'; currentVersion: string } - | { state: 'checking'; currentVersion: string } - | { state: 'not-available'; currentVersion: string; latestVersion?: string } - | { - state: 'available'; - currentVersion: string; - latestVersion: string; - } - | { - state: 'downloading'; - currentVersion: string; - latestVersion: string; - progress: AppUpdateProgress; - } - | { state: 'verifying'; currentVersion: string; latestVersion: string } - | { - state: 'downloaded'; - currentVersion: string; - latestVersion: string; - } - | { state: 'installing'; currentVersion: string; latestVersion: string } - | { - state: 'error'; - currentVersion: string; - message: string; - operation: 'check' | 'download' | 'install'; - latestVersion?: string; - }; - -export type AppUpdateInstallRequest = { - /** User consent from the trusted desktop renderer; this is a UX boundary, not a security boundary. */ - allowInterruptActiveTasks: boolean; -}; - -export type AppUpdateInstallResult = - | { ok: true } - | { ok: false; reason: 'active_tasks' } - | { ok: false; reason: 'not_downloaded' | 'install_failed' }; +export type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateStatus, +} from '../shared/app-update.js'; +export type AppUpdateProgress = SharedAppUpdateProgress; export interface AppUpdateService { start(): void; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 70b940e6b6..8ec1531807 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -70,6 +70,11 @@ import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/ import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { E2eFixtureState } from '@maka/core/e2e-fixture'; +import type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateStatus, +} from '../shared/app-update.js'; import type { GitReviewReadResult, GitReviewSource, @@ -273,50 +278,11 @@ export type PermissionOverlayStartResult = message?: string; }; -export type AppUpdateStatus = - | { state: 'idle'; currentVersion: string } - | { state: 'checking'; currentVersion: string } - | { state: 'not-available'; currentVersion: string; latestVersion?: string } - | { - state: 'available'; - currentVersion: string; - latestVersion: string; - } - | { - state: 'downloading'; - currentVersion: string; - latestVersion: string; - progress: { - percent: number; - bytesPerSecond?: number; - transferred?: number; - total?: number; - }; - } - | { state: 'verifying'; currentVersion: string; latestVersion: string } - | { - state: 'downloaded'; - currentVersion: string; - latestVersion: string; - } - | { state: 'installing'; currentVersion: string; latestVersion: string } - | { - state: 'error'; - currentVersion: string; - message: string; - operation: 'check' | 'download' | 'install'; - latestVersion?: string; - }; - -export type AppUpdateInstallRequest = { - /** User consent from the trusted desktop renderer; this is a UX boundary, not a security boundary. */ - allowInterruptActiveTasks: boolean; -}; - -export type AppUpdateInstallResult = - | { ok: true } - | { ok: false; reason: 'active_tasks' } - | { ok: false; reason: 'not_downloaded' | 'install_failed' }; +export type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateStatus, +} from '../shared/app-update.js'; export interface DesktopRuntimeHostProfileEntry { readonly profile: RuntimeHostProfile; diff --git a/apps/desktop/src/renderer/app-shell-app-update.ts b/apps/desktop/src/renderer/app-shell-app-update.ts deleted file mode 100644 index dd57d850e3..0000000000 --- a/apps/desktop/src/renderer/app-shell-app-update.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { SidebarUpdateReminder } from '@maka/ui'; -import type { AppUpdateStatus } from '../preload/bridge-contract.js'; - -/** - * The single answer to "which update states need the user". - * - * The updater runs with `autoDownload = true` and `autoInstallOnAppQuit = - * false` (app-update-service.ts), so `available` and `downloading` resolve - * themselves and ask nothing of anyone; only `downloaded` (which needs a - * restart) and `error` (which needs a retry) do. The footer used to carry a - * control through all four, which meant it spent most of its visible life - * asking for attention on behalf of a background download. - * - * This lives in one function rather than in the shell's render and its click - * handler both, because two copies of that list are free to disagree — and the - * one in the handler had already drifted into offering a retry for states that - * can no longer reach it. - */ -export function updateReminderFromStatus( - status: AppUpdateStatus | null, -): SidebarUpdateReminder | undefined { - if (status?.state === 'downloaded') { - return { state: 'downloaded', latestVersion: status.latestVersion }; - } - // `error` is the one state whose `latestVersion` is optional: a check that - // fails before it learns of a release has no version to name, and a reminder - // that cannot say which update failed is not worth a button. - if (status?.state === 'error' && status.latestVersion) { - return { state: 'error', latestVersion: status.latestVersion }; - } - return undefined; -} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6473dd7e7..caff1a2930 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -89,6 +89,7 @@ import { WorkbarTitlebarActions, useWorkbarController, } from './features/workbar'; +import { AppUpdateProvider } from './features/app-update/index.js'; import * as Goals from './features/goals'; import * as ModuleHub from './features/module-hub'; import { @@ -121,15 +122,10 @@ import { } from './plan-mode-panel'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { - AppUpdateStatus, DesktopSessionSummary, OnboardingSnapshot, } from '../preload/bridge-contract.js'; import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../preload/transcript-contract.js'; -import { - isAppUpdateInstallFailure, - requestDownloadedAppUpdate, -} from './app-update-install'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; @@ -165,7 +161,6 @@ import { presentContextCompactionResult, } from './app-shell-context-compaction'; import { AppShellTopbarActions } from './app-shell-chrome-actions'; -import { updateReminderFromStatus } from './app-shell-app-update'; import { AppShellDetailPanel } from './app-shell-detail-panel'; import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; @@ -287,13 +282,15 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { - + + + @@ -326,10 +323,7 @@ function AppShellContent({ setUiLocalePreference: Dispatch>; }) { const toastApi = useToast(); - const [appUpdateStatus, setAppUpdateStatus] = useState(null); const sharedSessionDialog = useSessionCollaborationDialog(); - const updateInstallInFlightRef = useRef(false); - const notifiedInstallErrorRef = useRef(null); const previousInterruptionShownRef = useRef(false); const { sessions, @@ -684,92 +678,6 @@ function AppShellContent({ cancelled = true; }; }, [appearanceHydrated, previousInterruptionCopy, toastApi]); - useEffect(() => { - if (!isAppUpdateInstallFailure(appUpdateStatus)) { - notifiedInstallErrorRef.current = null; - return; - } - if (notifiedInstallErrorRef.current === appUpdateStatus.message) return; - notifiedInstallErrorRef.current = appUpdateStatus.message; - toastApi.error( - shellCopy.updateInstallFailedTitle, - shellCopy.updateInstallManualFallback, - ); - }, [appUpdateStatus, shellCopy, toastApi]); - useEffect(() => { - let cancelled = false; - let receivedPush = false; - const unsubscribeUpdateStatus = window.maka.app.subscribeUpdateStatus((next) => { - receivedPush = true; - if (!cancelled) setAppUpdateStatus(next); - }); - void window.maka.app - .updateStatus() - .then((next) => { - if (!cancelled && !receivedPush) setAppUpdateStatus(next); - }) - .catch(() => {}); - return () => { - cancelled = true; - unsubscribeUpdateStatus(); - }; - }, []); - - const updateReminder = updateReminderFromStatus(appUpdateStatus); - // Dispatches on the task, not on the raw status: the footer is this - // callback's only caller and it only renders for the two states above, so - // reading the status again here would be the same "who needs the user" list - // maintained twice. - const openUpdateDownload = useCallback(() => { - if (updateReminder?.state === 'downloaded') { - if (updateInstallInFlightRef.current) return; - updateInstallInFlightRef.current = true; - void requestDownloadedAppUpdate({ - installUpdate: (input) => window.maka.app.installUpdate(input), - confirmActiveTasks: () => toastApi.confirm({ - title: shellCopy.updateActiveTasksTitle, - description: shellCopy.updateActiveTasksDescription, - confirmLabel: shellCopy.updateActiveTasksConfirm, - cancelLabel: shellCopy.updateActiveTasksCancel, - destructive: true, - }), - }) - .then((outcome) => { - if (outcome.kind !== 'failed') return; - if (outcome.reason === 'install_failed') return; - toastApi.error( - shellCopy.updateInstallFailedTitle, - shellCopy.updateInstallManualFallback, - ); - }) - .catch((error) => { - toastApi.error( - shellCopy.updateInstallFailedTitle, - localizedShellErrorMessage(error, shellCopy.updateInstallFailedFallback, uiLocale), - ); - }) - .finally(() => { - updateInstallInFlightRef.current = false; - }); - return; - } - if (!updateReminder) return; - void window.maka.app - .retryUpdateDownload() - .then((next) => { - if (next.state !== 'error') return; - toastApi.error( - shellCopy.updateRetryFailedTitle, - shellCopy.updateRetryFailedFallback, - ); - }) - .catch((error) => { - toastApi.error( - shellCopy.updateRetryFailedTitle, - localizedShellErrorMessage(error, shellCopy.updateRetryFailedFallback, uiLocale), - ); - }); - }, [updateReminder, shellCopy, toastApi, uiLocale]); // Persisted composer defaults seed the empty-state model, project path, and // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. @@ -2793,8 +2701,6 @@ function AppShellContent({ moduleMemory={navigationState.moduleMemory} onSelect={setNavSelection} onOpenSettings={openSettings} - updateReminder={updateReminder} - onOpenUpdate={openUpdateDownload} onNew={() => void createSession()} workHubEntry={workHubEnabled ? { active: workHubActive, diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 91755a2677..ffee7f5061 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -18,6 +18,7 @@ */ import type { ReactNode } from 'react'; +import { AppUpdateServicesProvider } from '../features/app-update/index.js'; import { ConnectionSettingsServicesProvider } from '../features/connection-settings'; import { GoalServicesProvider } from '../features/goals'; import { ModuleHubServicesProvider } from '../features/module-hub'; @@ -27,6 +28,7 @@ import { SessionNavigationServicesProvider } from '../features/session-navigatio import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; +import { createDesktopAppUpdateServices } from '../platform/desktop/create-app-update-services'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services'; import { createDesktopModuleHubServices } from '../platform/desktop/create-module-hub-services'; @@ -39,6 +41,7 @@ import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar export function createDesktopFeatureServices() { return { + appUpdate: createDesktopAppUpdateServices(), connectionSettings: createDesktopConnectionSettingsServices(), goal: createDesktopGoalServices(), moduleHub: createDesktopModuleHubServices(), @@ -56,24 +59,26 @@ export function DesktopFeatureServicesProvider(props: { readonly children?: ReactNode; }) { return ( - - - - - - - - - - {props.children} - - - - - - - - - + + + + + + + + + + + {props.children} + + + + + + + + + + ); } diff --git a/apps/desktop/src/renderer/features/app-update/README.md b/apps/desktop/src/renderer/features/app-update/README.md new file mode 100644 index 0000000000..626fda6924 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/README.md @@ -0,0 +1,52 @@ + + +# App Update feature + +This slice owns the Desktop renderer's App Update state and user actions. + +## Ownership + +- `AppUpdateProvider` is the only production owner of `useAppUpdateController`. +- `useAppUpdateController` is the only renderer subscriber to update status. +- `platform/desktop/create-app-update-services.ts` is the only adapter from the + Desktop bridge into this feature, and Desktop feature-services composition is + its only production importer. +- Main, preload, and the updater keep their existing IPC and download + lifecycle ownership; the shared contract lives in `src/shared/app-update.d.ts`. + +The controller subscribes before reading its initial snapshot. Pushes advance +an observation revision, so neither a late initial read nor a command result +can overwrite newer status. + +## Render scopes + +The provider publishes two independent projections: + +- About receives full status plus the guarded manual-check command. +- The sidebar footer receives only `downloaded` and versioned `error` + reminders plus the install/retry command. + +Download progress therefore has no reader when About is closed. When About is +open, only its update section reads progress. The unrelated AppShell and +Session Navigation chrome retain their existing elements and contexts. + +Do not re-export the controller hook from `index.ts`, subscribe in AppShell or +Settings, or pass update state through Session Navigation props. The boundary +and provider-scope tests enforce those constraints. diff --git a/apps/desktop/src/renderer/features/app-update/controller/use-app-update-controller.ts b/apps/desktop/src/renderer/features/app-update/controller/use-app-update-controller.ts new file mode 100644 index 0000000000..77c58da8af --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/controller/use-app-update-controller.ts @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAppUpdateServices } from '../services-context.js'; +import type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateStatus, +} from '../ports.js'; + +export interface AppUpdateController { + readonly status: AppUpdateStatus | null; + readonly checking: boolean; + readonly commands: { + checkForUpdates(): Promise; + retryUpdateDownload(): Promise; + installUpdate(input: AppUpdateInstallRequest): Promise; + }; +} + +/** + * Owns the renderer's sole App Update subscription. + * + * Each command captures the observation revision before its round trip. A + * returned snapshot is accepted only when no newer push arrived meanwhile, + * so a slow read can never overwrite a fresher status event. + */ +export function useAppUpdateController(): AppUpdateController { + const { appUpdate } = useAppUpdateServices(); + const [status, setStatus] = useState(null); + const [checking, setChecking] = useState(false); + const observationRevisionRef = useRef(0); + const checkInFlightRef = useRef | null>(null); + const mountedRef = useRef(false); + + const observe = useCallback((next: AppUpdateStatus) => { + observationRevisionRef.current += 1; + setStatus(next); + }, []); + + useEffect(() => { + let cancelled = false; + mountedRef.current = true; + const revisionBeforeRead = observationRevisionRef.current; + const unsubscribe = appUpdate.subscribeUpdateStatus((next) => { + if (!cancelled) observe(next); + }); + void appUpdate + .updateStatus() + .then((next) => { + if ( + !cancelled && + observationRevisionRef.current === revisionBeforeRead + ) { + observe(next); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + mountedRef.current = false; + unsubscribe(); + }; + }, [appUpdate, observe]); + + const acceptCommandSnapshotWhenCurrent = useCallback( + async (command: () => Promise): Promise => { + const revisionBeforeCommand = observationRevisionRef.current; + const next = await command(); + if ( + mountedRef.current && + observationRevisionRef.current === revisionBeforeCommand + ) { + observe(next); + } + return next; + }, + [observe], + ); + + const checkForUpdates = useCallback(() => { + if (checkInFlightRef.current) return checkInFlightRef.current; + setChecking(true); + const operation = acceptCommandSnapshotWhenCurrent( + () => appUpdate.checkForUpdates(), + ).finally(() => { + if (checkInFlightRef.current === operation) { + checkInFlightRef.current = null; + if (mountedRef.current) setChecking(false); + } + }); + checkInFlightRef.current = operation; + return operation; + }, [acceptCommandSnapshotWhenCurrent, appUpdate]); + const retryUpdateDownload = useCallback( + () => acceptCommandSnapshotWhenCurrent(() => appUpdate.retryUpdateDownload()), + [acceptCommandSnapshotWhenCurrent, appUpdate], + ); + const installUpdate = useCallback( + (input: AppUpdateInstallRequest) => appUpdate.installUpdate(input), + [appUpdate], + ); + + const commands = useMemo( + () => ({ checkForUpdates, retryUpdateDownload, installUpdate }), + [checkForUpdates, installUpdate, retryUpdateDownload], + ); + return useMemo( + () => ({ status, checking, commands }), + [checking, commands, status], + ); +} diff --git a/apps/desktop/src/renderer/features/app-update/index.ts b/apps/desktop/src/renderer/features/app-update/index.ts new file mode 100644 index 0000000000..5b4ef490c4 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { AppUpdateServicesProvider } from './services-context.js'; +export { AppUpdateProvider } from './ui/app-update-provider.js'; +export { AppUpdateAboutProjectionConsumer } from './ui/app-update-projection-context.js'; +export { aboutUpdateStatusDetail } from './model/about-status-detail.js'; +export type { AppUpdateServices } from './ports.js'; +export type { AppUpdateAboutProjection } from './ui/app-update-projection-context.js'; diff --git a/apps/desktop/src/renderer/features/app-update/locales/app-update-copy.ts b/apps/desktop/src/renderer/features/app-update/locales/app-update-copy.ts new file mode 100644 index 0000000000..cb976aab82 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/locales/app-update-copy.ts @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +export interface AppUpdateCopy { + readonly installFailedTitle: string; + readonly installFailedFallback: string; + readonly installManualFallback: string; + readonly activeTasksTitle: string; + readonly activeTasksDescription: string; + readonly activeTasksConfirm: string; + readonly activeTasksCancel: string; + readonly retryFailedTitle: string; + readonly retryFailedFallback: string; +} + +const COPY_BY_LOCALE = { + 'zh-CN': { + installFailedTitle: '无法安装更新', + installFailedFallback: '请稍后重试。', + installManualFallback: '请稍后重试,或手动下载最新版本。', + activeTasksTitle: '仍有任务正在运行', + activeTasksDescription: '仍有任务正在运行。更新会中断这些任务,是否继续?', + activeTasksConfirm: '仍然更新', + activeTasksCancel: '取消', + retryFailedTitle: '无法重新下载更新', + retryFailedFallback: '请稍后重试,或手动下载最新版本。', + }, + 'zh-TW': { + installFailedTitle: '無法安裝更新', + installFailedFallback: '請稍後重試。', + installManualFallback: '請稍後重試,或手動下載最新版本。', + activeTasksTitle: '仍有任務正在執行', + activeTasksDescription: '仍有任務正在執行。更新會中斷這些任務,是否繼續?', + activeTasksConfirm: '仍然更新', + activeTasksCancel: '取消', + retryFailedTitle: '無法重新下載更新', + retryFailedFallback: '請稍後重試,或手動下載最新版本。', + }, + en: { + installFailedTitle: 'Could not install update', + installFailedFallback: 'Try again later.', + installManualFallback: 'Try again later, or download the latest version manually.', + activeTasksTitle: 'Tasks are still running', + activeTasksDescription: 'Tasks are still running. Updating will interrupt them. Continue?', + activeTasksConfirm: 'Update anyway', + activeTasksCancel: 'Cancel', + retryFailedTitle: 'Could not retry update download', + retryFailedFallback: 'Try again later, or download the latest version manually.', + }, +} satisfies UiCatalog; + +export function getAppUpdateCopy(locale: UiLocale): AppUpdateCopy { + return COPY_BY_LOCALE[locale]; +} + +export function localizedAppUpdateErrorMessage( + error: unknown, + fallback: string, + locale: UiLocale, +): string { + return generalizedErrorMessageForLocale(error, fallback, locale); +} diff --git a/apps/desktop/src/renderer/features/app-update/model/about-status-detail.ts b/apps/desktop/src/renderer/features/app-update/model/about-status-detail.ts new file mode 100644 index 0000000000..6067abe411 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/model/about-status-detail.ts @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AppUpdateStatus } from '../ports.js'; + +/** + * The About-page copy the status line reads. Structural on purpose: the + * feature owns updater state, not Settings copy, so it names only the keys it + * needs and the Settings catalog satisfies it without an import edge. + */ +export interface AboutUpdateStatusCopy { + readonly updateIdle: string; + readonly checkingForUpdates: string; + readonly updateNotAvailable: string; + readonly updateAvailable: (version: string) => string; + readonly updateDownloading: (version: string, percent: number) => string; + readonly updateVerifying: (version: string) => string; + readonly updateDownloaded: (version: string) => string; + readonly updateInstalling: (version: string) => string; + readonly updateCheckFailedDetail: (message: string) => string; +} + +/** + * Map updater state to About-page detail copy (pure for unit tests). + * + * There is no dev-build branch: a dev checkout renders no status line at all, + * so the "development builds do not check GitHub releases" sentence this used + * to return would only have restated the channel sentence above it. Whether + * the line exists is the About page's decision; this only says what it reads. + * `errorDetail` lets the page localize a raw updater error before it is shown. + */ +export function aboutUpdateStatusDetail( + status: AppUpdateStatus | null, + copy: AboutUpdateStatusCopy, + options: { + readonly isDevBuild: boolean; + readonly errorDetail?: (message: string) => string; + }, +): string { + if (!status || status.state === 'idle') return copy.updateIdle; + if (status.state === 'checking') return copy.checkingForUpdates; + if (status.state === 'not-available') return copy.updateNotAvailable; + if (status.state === 'available') return copy.updateAvailable(status.latestVersion); + if (status.state === 'downloading') { + return copy.updateDownloading(status.latestVersion, Math.round(status.progress.percent)); + } + if (status.state === 'verifying') return copy.updateVerifying(status.latestVersion); + if (status.state === 'downloaded') return copy.updateDownloaded(status.latestVersion); + if (status.state === 'installing') return copy.updateInstalling(status.latestVersion); + return copy.updateCheckFailedDetail( + options.errorDetail ? options.errorDetail(status.message) : status.message, + ); +} diff --git a/apps/desktop/src/renderer/app-update-install.ts b/apps/desktop/src/renderer/features/app-update/model/install-update.ts similarity index 85% rename from apps/desktop/src/renderer/app-update-install.ts rename to apps/desktop/src/renderer/features/app-update/model/install-update.ts index e6c8ff8530..a38e8828df 100644 --- a/apps/desktop/src/renderer/app-update-install.ts +++ b/apps/desktop/src/renderer/features/app-update/model/install-update.ts @@ -21,9 +21,9 @@ import type { AppUpdateInstallRequest, AppUpdateInstallResult, AppUpdateStatus, -} from '../preload/bridge-contract.js'; +} from '../ports.js'; -export type AppUpdateInstallOutcome = +type AppUpdateInstallOutcome = | { kind: 'install-started' } | { kind: 'cancelled' } | { kind: 'failed'; reason: 'not_downloaded' | 'install_failed' }; @@ -40,11 +40,18 @@ export async function requestDownloadedAppUpdate(input: { }): Promise { const guarded = await input.installUpdate({ allowInterruptActiveTasks: false }); if (guarded.ok) return { kind: 'install-started' }; - if (guarded.reason !== 'active_tasks') return { kind: 'failed', reason: guarded.reason }; + if (guarded.reason !== 'active_tasks') { + return { kind: 'failed', reason: guarded.reason }; + } if (!await input.confirmActiveTasks()) return { kind: 'cancelled' }; const authorized = await input.installUpdate({ allowInterruptActiveTasks: true }); return authorized.ok ? { kind: 'install-started' } - : { kind: 'failed', reason: authorized.reason === 'active_tasks' ? 'install_failed' : authorized.reason }; + : { + kind: 'failed', + reason: authorized.reason === 'active_tasks' + ? 'install_failed' + : authorized.reason, + }; } diff --git a/apps/desktop/src/renderer/features/app-update/model/update-reminder.ts b/apps/desktop/src/renderer/features/app-update/model/update-reminder.ts new file mode 100644 index 0000000000..166b0e7a7f --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/model/update-reminder.ts @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AppUpdateStatus } from '../ports.js'; + +export interface AppUpdateReminder { + readonly state: 'downloaded' | 'error'; + readonly latestVersion: string; +} + +/** The single answer to "which update states need the user". */ +export function updateReminderFromStatus( + status: AppUpdateStatus | null, +): AppUpdateReminder | undefined { + if (status?.state === 'downloaded') { + return { state: 'downloaded', latestVersion: status.latestVersion }; + } + if (status?.state === 'error' && status.latestVersion) { + return { state: 'error', latestVersion: status.latestVersion }; + } + return undefined; +} diff --git a/apps/desktop/src/renderer/features/app-update/ports.ts b/apps/desktop/src/renderer/features/app-update/ports.ts new file mode 100644 index 0000000000..74d1a77eec --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/ports.ts @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateStatus, +} from '../../../shared/app-update.js'; + +export type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateStatus, +} from '../../../shared/app-update.js'; + +export type AppUpdateUnsubscribe = () => void; + +/** The minimum environment capability needed by the App Update feature. */ +export interface AppUpdateService { + updateStatus(): Promise; + checkForUpdates(): Promise; + retryUpdateDownload(): Promise; + installUpdate(input: AppUpdateInstallRequest): Promise; + subscribeUpdateStatus(handler: (status: AppUpdateStatus) => void): AppUpdateUnsubscribe; +} + +export interface AppUpdateServices { + appUpdate: AppUpdateService; +} diff --git a/apps/desktop/src/renderer/features/app-update/services-context.tsx b/apps/desktop/src/renderer/features/app-update/services-context.tsx new file mode 100644 index 0000000000..dc2f67319a --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/services-context.tsx @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { AppUpdateServices } from './ports.js'; + +const { Provider, useServices } = createServicesContext('AppUpdateServicesProvider'); + +export const AppUpdateServicesProvider = Provider; + +export function useAppUpdateServices(): AppUpdateServices { + return useServices(); +} diff --git a/apps/desktop/src/renderer/features/app-update/testing.ts b/apps/desktop/src/renderer/features/app-update/testing.ts new file mode 100644 index 0000000000..63d8e5c4c9 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/testing.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AppUpdateServices } from './ports.js'; + +export { AppUpdateServicesProvider } from './services-context.js'; +export { AppUpdateProvider } from './ui/app-update-provider.js'; +export { AppUpdateAboutProjectionConsumer } from './ui/app-update-projection-context.js'; +export type { AppUpdateAboutProjection } from './ui/app-update-projection-context.js'; +export { + isAppUpdateInstallFailure, + requestDownloadedAppUpdate, +} from './model/install-update.js'; +export { + useAppUpdateController, + type AppUpdateController, +} from './controller/use-app-update-controller.js'; +export type { + AppUpdateInstallRequest, + AppUpdateInstallResult, + AppUpdateServices, + AppUpdateStatus, +} from './ports.js'; + +const noopSubscription = (): (() => void) => () => undefined; + +export function createFakeAppUpdateServices( + overrides: Partial = {}, +): AppUpdateServices { + return { + appUpdate: { + updateStatus: async () => ({ state: 'idle', currentVersion: '0.0.0' }), + checkForUpdates: async () => ({ state: 'idle', currentVersion: '0.0.0' }), + retryUpdateDownload: async () => ({ state: 'idle', currentVersion: '0.0.0' }), + installUpdate: async () => ({ ok: false, reason: 'not_downloaded' }), + subscribeUpdateStatus: noopSubscription, + }, + ...overrides, + }; +} diff --git a/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts b/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts new file mode 100644 index 0000000000..70cbce9b4b --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.ts @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext } from 'react'; +import type { AppUpdateStatus } from '../ports.js'; + +export interface AppUpdateAboutProjection { + readonly status: AppUpdateStatus | null; + readonly checking: boolean; + readonly checkForUpdates: () => Promise; +} + +const missingProvider = async (): Promise => { + throw new Error('AppUpdateProvider is missing'); +}; + +const inactiveAboutProjection: AppUpdateAboutProjection = { + status: null, + checking: false, + checkForUpdates: missingProvider, +}; + +const AppUpdateAboutProjectionContext = createContext( + inactiveAboutProjection, +); + +export const AppUpdateAboutProjectionProvider = AppUpdateAboutProjectionContext.Provider; +export const AppUpdateAboutProjectionConsumer = AppUpdateAboutProjectionContext.Consumer; diff --git a/apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx b/apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx new file mode 100644 index 0000000000..6069667ccf --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; +import { + SidebarUpdateProjectionProvider, + useToast, + useUiLocale, + type SidebarUpdateProjection, +} from '@maka/ui'; +import { useAppUpdateController } from '../controller/use-app-update-controller.js'; +import { + getAppUpdateCopy, + localizedAppUpdateErrorMessage, +} from '../locales/app-update-copy.js'; +import { + isAppUpdateInstallFailure, + requestDownloadedAppUpdate, +} from '../model/install-update.js'; +import { updateReminderFromStatus } from '../model/update-reminder.js'; +import { + AppUpdateAboutProjectionProvider, + type AppUpdateAboutProjection, +} from './app-update-projection-context.js'; + +/** + * Owns App Update below AppShell and publishes only reader-local projections. + * + * Progress pushes update the About projection. The sidebar projection keeps + * the same identity until a downloaded/error reminder actually changes, and + * `children` is the element built by AppShell's parent render. React can + * therefore retain the unrelated shell instead of widening updater state back + * to the renderer root. + */ +export function AppUpdateProvider(props: { readonly children?: ReactNode }) { + const controller = useAppUpdateController(); + const toast = useToast(); + const locale = useUiLocale(); + const copy = getAppUpdateCopy(locale); + const installInFlightRef = useRef(false); + const notifiedInstallErrorRef = useRef(null); + + useEffect(() => { + if (!isAppUpdateInstallFailure(controller.status)) { + notifiedInstallErrorRef.current = null; + return; + } + if (notifiedInstallErrorRef.current === controller.status.message) return; + notifiedInstallErrorRef.current = controller.status.message; + toast.error(copy.installFailedTitle, copy.installManualFallback); + }, [controller.status, copy, toast]); + + const nextReminder = updateReminderFromStatus(controller.status); + const reminderState = nextReminder?.state; + const reminderVersion = nextReminder?.latestVersion; + const reminder = useMemo( + () => reminderState === undefined || reminderVersion === undefined + ? undefined + : { state: reminderState, latestVersion: reminderVersion }, + [reminderState, reminderVersion], + ); + + const openUpdate = useCallback(() => { + if (reminder?.state === 'downloaded') { + if (installInFlightRef.current) return; + installInFlightRef.current = true; + void requestDownloadedAppUpdate({ + installUpdate: controller.commands.installUpdate, + confirmActiveTasks: () => toast.confirm({ + title: copy.activeTasksTitle, + description: copy.activeTasksDescription, + confirmLabel: copy.activeTasksConfirm, + cancelLabel: copy.activeTasksCancel, + destructive: true, + }), + }) + .then((outcome) => { + if (outcome.kind !== 'failed' || outcome.reason === 'install_failed') return; + toast.error(copy.installFailedTitle, copy.installManualFallback); + }) + .catch((error) => { + toast.error( + copy.installFailedTitle, + localizedAppUpdateErrorMessage(error, copy.installFailedFallback, locale), + ); + }) + .finally(() => { + installInFlightRef.current = false; + }); + return; + } + if (!reminder) return; + void controller.commands + .retryUpdateDownload() + .then((next) => { + if (next.state !== 'error') return; + toast.error(copy.retryFailedTitle, copy.retryFailedFallback); + }) + .catch((error) => { + toast.error( + copy.retryFailedTitle, + localizedAppUpdateErrorMessage(error, copy.retryFailedFallback, locale), + ); + }); + }, [controller.commands, copy, locale, reminder, toast]); + + const sidebar = useMemo( + () => ({ reminder, onOpenUpdate: reminder ? openUpdate : undefined }), + [openUpdate, reminder], + ); + const about = useMemo( + () => ({ + status: controller.status, + checking: controller.checking, + checkForUpdates: controller.commands.checkForUpdates, + }), + [controller.checking, controller.commands.checkForUpdates, controller.status], + ); + + return ( + + + {props.children} + + + ); +} diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index 9b455000a2..7694103fc4 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -34,7 +34,6 @@ import { type SessionRailChrome, type SessionRailData, type SessionRowActions, - type SidebarUpdateReminder, } from '@maka/ui'; import { useSessionNavigationController } from '../controller/use-session-navigation-controller.js'; import type { SessionNavigationRowActions } from '../controller/session-row-actions.js'; @@ -51,13 +50,11 @@ export interface SessionNavigationChromeInput { selection: NavSelection; scheduledTasks?: readonly ScheduledTask[]; moduleMemory?: NavModuleMemory; - updateReminder?: SidebarUpdateReminder; workHubActive: boolean; workHubEntry?: { active: boolean; label: string; onSelect(): void }; projectActions?: ProjectRowActions; onSelect(selection: NavSelection): void; onOpenSettings(): void; - onOpenUpdate?(): void; onNew(): void; onExitWorkHub(): void; onSelectSession(sessionId: string): void; @@ -216,8 +213,6 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) props.onNew(); }, onOpenSettings: props.onOpenSettings, - updateReminder: props.updateReminder, - onOpenUpdate: props.onOpenUpdate, workHubEntry: props.workHubEntry, }; diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 3c6c50b745..80407441a1 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -476,15 +476,6 @@ type ShellCopy = { resizeConversationList: string; skipErrorTitle: string; tryAgainLater: string; - updateInstallFailedTitle: string; - updateInstallFailedFallback: string; - updateInstallManualFallback: string; - updateActiveTasksTitle: string; - updateActiveTasksDescription: string; - updateActiveTasksConfirm: string; - updateActiveTasksCancel: string; - updateRetryFailedTitle: string; - updateRetryFailedFallback: string; loading: string; goToModels: string; boundaryUnreadableTitle: string; @@ -1214,15 +1205,6 @@ const SHELL_COPY_BY_LOCALE = { resizeConversationList: '调整任务列表宽度', skipErrorTitle: '跳过失败', tryAgainLater: '请稍后重试。', - updateInstallFailedTitle: '无法安装更新', - updateInstallFailedFallback: '请稍后重试。', - updateInstallManualFallback: '请稍后重试,或手动下载最新版本。', - updateActiveTasksTitle: '仍有任务正在运行', - updateActiveTasksDescription: '仍有任务正在运行。更新会中断这些任务,是否继续?', - updateActiveTasksConfirm: '仍然更新', - updateActiveTasksCancel: '取消', - updateRetryFailedTitle: '无法重新下载更新', - updateRetryFailedFallback: '请稍后重试,或手动下载最新版本。', loading: '加载中', goToModels: '去模型', boundaryUnreadableTitle: '暂时读不到这个任务的权限', @@ -1716,15 +1698,6 @@ const SHELL_COPY_BY_LOCALE = { resizeConversationList: '調整任務列表寬度', skipErrorTitle: '跳過失敗', tryAgainLater: '請稍後重試。', - updateInstallFailedTitle: '無法安裝更新', - updateInstallFailedFallback: '請稍後重試。', - updateInstallManualFallback: '請稍後重試,或手動下載最新版本。', - updateActiveTasksTitle: '仍有任務正在執行', - updateActiveTasksDescription: '仍有任務正在執行。更新會中斷這些任務,是否繼續?', - updateActiveTasksConfirm: '仍然更新', - updateActiveTasksCancel: '取消', - updateRetryFailedTitle: '無法重新下載更新', - updateRetryFailedFallback: '請稍後重試,或手動下載最新版本。', loading: '載入中', goToModels: '去模型', boundaryUnreadableTitle: '暫時讀不到這個任務的權限', @@ -2264,15 +2237,6 @@ const SHELL_COPY_BY_LOCALE = { resizeConversationList: 'Resize task list', skipErrorTitle: 'Could not skip onboarding', tryAgainLater: 'Try again later.', - updateInstallFailedTitle: 'Could not install update', - updateInstallFailedFallback: 'Try again later.', - updateInstallManualFallback: 'Try again later, or download the latest version manually.', - updateActiveTasksTitle: 'Tasks are still running', - updateActiveTasksDescription: 'Tasks are still running. Updating will interrupt them. Continue?', - updateActiveTasksConfirm: 'Update anyway', - updateActiveTasksCancel: 'Cancel', - updateRetryFailedTitle: 'Could not retry update download', - updateRetryFailedFallback: 'Try again later, or download the latest version manually.', loading: 'Loading', goToModels: 'Go to Models', boundaryUnreadableTitle: 'Could not read this task’s permissions', diff --git a/apps/desktop/src/renderer/platform/desktop/create-app-update-services.ts b/apps/desktop/src/renderer/platform/desktop/create-app-update-services.ts new file mode 100644 index 0000000000..9b2acb5f25 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-app-update-services.ts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { AppUpdateServices } from '../../features/app-update/index.js'; + +export type DesktopAppUpdateBridge = Pick; + +/** The only Desktop-to-App-Update adapter. */ +export function createDesktopAppUpdateServices( + bridge: DesktopAppUpdateBridge = window.maka, +): AppUpdateServices { + return { + appUpdate: bridge.app, + }; +} diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index ad7aa8ed1d..dc61c1a327 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode, type RefObject } from 'react'; import { Heading, HStack, @@ -29,33 +29,107 @@ import { VStack, } from '@astryxdesign/core'; import { Kbd } from '@astryxdesign/core/Kbd'; -import { Banner, Button, useMountedRef, useToast, useUiLocale } from '@maka/ui'; -import type { AppUpdateStatus } from '../../preload/bridge-contract.js'; +import { + Banner, + Button, + useMountedRef, + useToast, + useUiLocale, + type ToastApi, +} from '@maka/ui'; +import { + AppUpdateAboutProjectionConsumer, + aboutUpdateStatusDetail, + type AppUpdateAboutProjection, +} from '../features/app-update/index.js'; import { SettingsPage, SettingsRow, SettingsSection } from './settings-section.js'; import { settingsActionErrorMessage } from './settings-error-copy.js'; import { SettingsSkeletonStack } from './settings-skeleton.js'; import { useActionGuard } from './use-action-guard.js'; -import { aboutChannelFacts, aboutUpdateStatusDetail } from './about-update-status.js'; -import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; +import { aboutChannelFacts } from './about-update-status.js'; +import { + getSettingsPreferencesCopy, + type SettingsPreferencesCopy, +} from '../locales/settings-preferences-copy.js'; import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, } from '../default-runtime-host-operation.js'; type AppInfo = Awaited>; +type AboutCopy = SettingsPreferencesCopy['about']; const ISSUE_TRACKER_URL = 'https://github.com/apache/maka/issues'; +/** + * The status line and its manual check, rendered only for a packaged install. + * + * Update state is not this page's to own: the App Update feature holds the + * renderer's sole updater subscription above AppShell and publishes About's + * projection, so the row reads status from the consumer and issues the + * feature's guarded check command instead of touching the bridge. It is a + * component rather than the consumer's render callback because the action + * guard is a hook. + */ +function AboutUpdateRow(props: { + readonly update: AppUpdateAboutProjection; + readonly copy: AboutCopy; + readonly locale: ReturnType; + readonly toast: ToastApi; + readonly mountedRef: RefObject; + readonly isDevBuild: boolean; +}) { + const { update, copy, locale, toast, mountedRef, isDevBuild } = props; + const checkUpdateGuard = useActionGuard<'check'>(); + + async function checkForUpdates() { + if (!checkUpdateGuard.begin('check')) return; + try { + const status = await update.checkForUpdates(); + if (status.state === 'error') { + toast.error( + copy.updateCheckFailed, + copy.updateCheckFailedDetail(settingsActionErrorMessage(status.message, locale)), + ); + } + } catch (error) { + if (mountedRef.current) { + toast.error(copy.updateCheckFailed, settingsActionErrorMessage(error, locale)); + } + } finally { + checkUpdateGuard.finish(); + } + } + + /* The status line says what the button would tell you, so it carries no + label of its own. It wraps rather than crushes: at the 480px window floor + the sentence needs the full width. */ + return ( + + {aboutUpdateStatusDetail(update.status, copy, { + isDevBuild, + errorDetail: (message) => settingsActionErrorMessage(message, locale), + })} +