diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 73d50bfc1d..2d7de72919 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", @@ -291,6 +289,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", @@ -308,18 +313,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": 8, "bridgePaths": { @@ -723,12 +716,8 @@ "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 81, + "importDeclarations": 79, "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, @@ -779,13 +768,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, @@ -800,7 +789,7 @@ "useShellRunUpdates": 1, "useShellSearch": 1, "useStableActions": 6, - "useState": 17, + "useState": 16, "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, @@ -813,7 +802,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, @@ -827,7 +815,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, @@ -839,6 +826,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, @@ -904,8 +892,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 124, - "nonTriviaTokens": 15543 + "importSpecifiers": 121, + "nonTriviaTokens": 15012 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1095,15 +1083,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": {}, @@ -2292,18 +2271,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 }, @@ -2312,6 +2288,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, @@ -4887,13 +4864,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__/app-update-boundary.test.ts b/apps/desktop/src/main/__tests__/app-update-boundary.test.ts new file mode 100644 index 0000000000..9716238b24 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-boundary.test.ts @@ -0,0 +1,419 @@ +/* + * 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('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('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', + ]); + }); + + 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..893b0fb21a --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-services-adapter.test.ts @@ -0,0 +1,76 @@ +/* + * 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 forwards exactly the five update capabilities to the app bridge', async () => { + const calls: string[] = []; + const app = { + updateStatus: async () => { + calls.push('updateStatus'); + return { state: 'idle' as const, currentVersion: '1.0.0' }; + }, + checkForUpdates: async () => { + calls.push('checkForUpdates'); + return { state: 'checking' as const, currentVersion: '1.0.0' }; + }, + retryUpdateDownload: async () => { + calls.push('retryUpdateDownload'); + return { state: 'idle' as const, currentVersion: '1.0.0' }; + }, + installUpdate: async (input: { allowInterruptActiveTasks: boolean }) => { + calls.push(`installUpdate:${String(input.allowInterruptActiveTasks)}`); + return { ok: true as const }; + }, + subscribeUpdateStatus: () => { + calls.push('subscribeUpdateStatus'); + return () => undefined; + }, + openArtifactPath: async () => undefined, + }; + const bridge = { app } as unknown as DesktopAppUpdateBridge; + + const services = createDesktopAppUpdateServices(bridge); + + await services.appUpdate.updateStatus(); + await services.appUpdate.checkForUpdates(); + await services.appUpdate.retryUpdateDownload(); + await services.appUpdate.installUpdate({ allowInterruptActiveTasks: true }); + services.appUpdate.subscribeUpdateStatus(() => undefined)(); + + assert.deepEqual(calls, [ + 'updateStatus', + 'checkForUpdates', + 'retryUpdateDownload', + 'installUpdate:true', + 'subscribeUpdateStatus', + ]); + assert.deepEqual(Object.keys(services.appUpdate).sort(), [ + 'checkForUpdates', + 'installUpdate', + 'retryUpdateDownload', + 'subscribeUpdateStatus', + 'updateStatus', + ]); +}); diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index 6e1b563386..1433308b1d 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 779c6651a6..b4169fc59d 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, @@ -272,50 +277,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 dbe0ad682e..c0a8042252 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 { @@ -122,15 +123,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'; @@ -166,7 +162,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'; @@ -288,18 +283,20 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { - - {(taskEntry) => ( - - )} - + + + {(taskEntry) => ( + + )} + + @@ -334,10 +331,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, @@ -679,92 +673,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. @@ -2782,8 +2690,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..49d1107324 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/index.ts @@ -0,0 +1,24 @@ +/* + * 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 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..695ab79621 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/locales/app-update-copy.ts @@ -0,0 +1,72 @@ +/* + * 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 { 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]; +} 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..ca39a24051 --- /dev/null +++ b/apps/desktop/src/renderer/features/app-update/ui/app-update-projection-context.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 { createContext, useContext, type ReactNode } from 'react'; +import type { AppUpdateStatus } from '../ports.js'; + +export interface AppUpdateAboutProjection { + readonly status: AppUpdateStatus | null; + readonly checking: boolean; + readonly checkForUpdates: () => Promise; +} + +const AppUpdateAboutProjectionContext = createContext(null); + +export const AppUpdateAboutProjectionProvider = AppUpdateAboutProjectionContext.Provider; + +/** Reads the About projection; a mount outside `AppUpdateProvider` is a bug, not a quiet idle. */ +export function AppUpdateAboutProjectionConsumer(props: { + readonly children: (projection: AppUpdateAboutProjection) => ReactNode; +}): ReactNode { + const projection = useContext(AppUpdateAboutProjectionContext); + if (!projection) throw new Error('AppUpdateProvider is missing'); + return props.children(projection); +} 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..a177e0751a --- /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, +} from '../locales/app-update-copy.js'; +import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; +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, + generalizedErrorMessageForLocale(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, + generalizedErrorMessageForLocale(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..d679ab9046 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-app-update-services.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 { 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: { + updateStatus: () => bridge.app.updateStatus(), + checkForUpdates: () => bridge.app.checkForUpdates(), + retryUpdateDownload: () => bridge.app.retryUpdateDownload(), + installUpdate: (input) => bridge.app.installUpdate(input), + subscribeUpdateStatus: (handler) => bridge.app.subscribeUpdateStatus(handler), + }, + }; +} diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index e698e47fb3..ea5366c7b1 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -17,10 +17,13 @@ * under the License. */ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode, type RefObject } from 'react'; import { Link, Text } from '@astryxdesign/core'; -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, + 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'; @@ -57,16 +60,76 @@ const RELEASES_URL = `${REPOSITORY_URL}/releases`; /* The ghost `sm` button pads its label by one spacing step; without the same inset the link's text sits 12px further right than the buttons' text. */ const linkInRowEnd = { paddingInline: 'var(--spacing-3)' } as const; +type AboutCopy = ReturnType['about']; + +/** + * The updater's row, 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 + * instead of touching the bridge. A component rather than the consumer's + * render callback because the action guard is a hook. + */ +function AboutUpdateStatusRow(props: { + readonly update: AppUpdateAboutProjection; + readonly copy: AboutCopy; + readonly locale: ReturnType; + readonly toast: ToastApi; + readonly mountedRef: RefObject; +}) { + const { update, copy, locale, toast, mountedRef } = props; + const checkUpdateGuard = useActionGuard<'check'>(); + const row = aboutUpdateRow(update.status, copy, { + errorDetail: (message) => settingsActionErrorMessage(message, locale), + }); + + async function checkForUpdates() { + if (!checkUpdateGuard.begin('check')) return; + try { + const status = await update.checkForUpdates(); + if (status.state === 'error') { + toast.error( + copy.updateFailed[status.operation], + settingsActionErrorMessage(status.message, locale), + ); + } + } catch (error) { + if (mountedRef.current) { + toast.error(copy.updateFailed.check, settingsActionErrorMessage(error, locale)); + } + } finally { + checkUpdateGuard.finish(); + } + } + + return ( + void checkForUpdates()} + label={copy.checkForUpdates} + /> + )} + /> + ); +} + export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { const locale = useUiLocale(); const copy = getSettingsPreferencesCopy(locale).about; const [info, setInfo] = useState(null); const [infoError, setInfoError] = useState(null); const [copyingDiagnostics, setCopyingDiagnostics] = useState(false); - const [updateStatus, setUpdateStatus] = useState(null); - const [checkingUpdate, setCheckingUpdate] = useState(false); const diagnosticCopyGuard = useActionGuard<'copy'>(); - const checkUpdateGuard = useActionGuard<'check'>(); const aboutPageMountedRef = useMountedRef(); const toast = useToast(); @@ -96,23 +159,6 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { }; }, [copy.loadFailed, locale, toast]); - useEffect(() => { - let cancelled = false; - window.maka.app - .updateStatus() - .then((status) => { - if (!cancelled) setUpdateStatus(status); - }) - .catch(() => undefined); - const unsubscribe = window.maka.app.subscribeUpdateStatus((status) => { - if (!cancelled) setUpdateStatus(status); - }); - return () => { - cancelled = true; - unsubscribe(); - }; - }, []); - async function copyDiagnostics() { if (!diagnosticCopyGuard.begin('copy')) return; setCopyingDiagnostics(true); @@ -129,28 +175,6 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { } } - async function checkForUpdates() { - if (!checkUpdateGuard.begin('check')) return; - setCheckingUpdate(true); - try { - const status = await window.maka.app.checkForUpdates(); - if (aboutPageMountedRef.current) setUpdateStatus(status); - if (status.state === 'error') { - toast.error( - copy.updateFailed[status.operation], - settingsActionErrorMessage(status.message, locale), - ); - } - } catch (error) { - if (aboutPageMountedRef.current) { - toast.error(copy.updateFailed.check, settingsActionErrorMessage(error, locale)); - } - } finally { - checkUpdateGuard.finish(); - if (aboutPageMountedRef.current) setCheckingUpdate(false); - } - } - let identity: ReactNode; if (!info && !infoError) { identity = ( @@ -172,9 +196,6 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { ); } else { - const update = aboutUpdateRow(updateStatus, copy, { - errorDetail: (message) => settingsActionErrorMessage(message, locale), - }); identity = ( /* The two facts a user opens this page for, as the unlabeled lead group: which build this is, and whether it is current. Unlabeled because the @@ -188,23 +209,17 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { {info.buildMode === 'dev' ? null : ( - void checkForUpdates()} - label={copy.checkForUpdates} + + {(update) => ( + )} - /> + )} ); diff --git a/apps/desktop/src/shared/app-update.d.ts b/apps/desktop/src/shared/app-update.d.ts new file mode 100644 index 0000000000..9e553e1f4e --- /dev/null +++ b/apps/desktop/src/shared/app-update.d.ts @@ -0,0 +1,65 @@ +/* + * 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 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' }; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 0b1bd9efb4..eb1d56b488 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -42,6 +42,11 @@ import type { import type { HealthSignal, HealthSnapshot } from '@maka/core/health'; import type { DesktopExternalSessionCatalogItem } from '../../src/preload/external-session-catalog'; import type { AppUpdateStatus } from '../../src/preload/bridge-contract'; +import { + AppUpdateProvider, + AppUpdateServicesProvider, + type AppUpdateServices, +} from '../../src/renderer/features/app-update/index.js'; import type { SessionSummary } from '@maka/core/session'; import { revisionFamilySessionIds } from '@maka/core/session-revisions'; import type { @@ -950,6 +955,24 @@ const makaBridge = { const withSettingsBridge = withScopedMakaBridge(makaBridge); +/** + * What the production App Update provider reads inside `SettingsStory`. Each + * call goes to `window.maka.app` at call time rather than capturing the shared + * fixture: a story's decorator installs its scoped bridge in a layout effect, + * after this module evaluated, and the channel stories below override + * `updateStatus` there. Capturing `makaBridge.app` here would show every About + * story the shared idle status. + */ +const settingsAppUpdateServices: AppUpdateServices = { + appUpdate: { + updateStatus: () => window.maka.app.updateStatus(), + checkForUpdates: () => window.maka.app.checkForUpdates(), + retryUpdateDownload: () => window.maka.app.retryUpdateDownload(), + installUpdate: (input) => window.maka.app.installUpdate(input), + subscribeUpdateStatus: (handler) => window.maka.app.subscribeUpdateStatus(handler), + }, +}; + /** * A PACKAGED install, which the shared fixture cannot be: it is a dev checkout, * and `buildMode` short-circuits the About lead before `updateChannel` is ever @@ -1689,7 +1712,11 @@ function fieldChrome(element: HTMLElement) { function SettingsStory(props: SettingsStoryProps) { return ( - + + + + + ); } @@ -2926,6 +2953,31 @@ export const AboutUpdateFailed: Story = { render: () => , }; +// Interaction: 检查更新 on a packaged release that has not checked yet. The +// button issues the App Update feature's guarded command — the page itself +// never touches the bridge — and the row's label moves from 尚未检查更新 to +// 已是最新版本 once the check returns `not-available`. A dev checkout has no +// row to click, which is why this is not the `About` story's play. +export const AboutCheckForUpdates: Story = { + decorators: [ + withPackagedChannelBridge({ + updateChannel: 'release', + appVersion: '0.2.0', + updateStatus: { state: 'idle', currentVersion: '0.2.0' }, + }), + ], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const check = await canvas.findByRole('button', { name: '检查更新' }); + expect(check).toBeEnabled(); + await userEvent.click(check); + await waitFor(() => { + expect(canvas.getByText('已是最新版本')).toBeInTheDocument(); + }); + }, +}; + // Real path: 设置 → 已归档任务, after archiving tasks from the rail's row menu. export const ArchivedTasks: Story = { decorators: [withSettingsBridge], diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d82d4b30bc..36049f3bb8 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 248 files — blocker 0, reimplementation 0, polish 1, aligned 247. +**Totals:** 250 files — blocker 0, reimplementation 0, polish 1, aligned 249. ## Exclusions (explicit) @@ -43,6 +43,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/composition/desktop-feature-services.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/custom-pet-companion.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/error-boundary.tsx` | other | Button, Card | aligned — uses Astryx (Button, Card) | aligned | +| `apps/desktop/src/renderer/features/app-update/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx` | dialog-overlay | VStack | aligned — uses Astryx (VStack) | aligned | | `apps/desktop/src/renderer/features/connection-settings/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index acc43c996b..ff1c467b38 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -14,6 +14,8 @@ apps/desktop/src/renderer/composer-mentions.tsx apps/desktop/src/renderer/composition/desktop-feature-services.tsx apps/desktop/src/renderer/custom-pet-companion.tsx apps/desktop/src/renderer/error-boundary.tsx +apps/desktop/src/renderer/features/app-update/services-context.tsx +apps/desktop/src/renderer/features/app-update/ui/app-update-provider.tsx apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx apps/desktop/src/renderer/features/connection-settings/services-context.tsx apps/desktop/src/renderer/features/goals/services-context.tsx diff --git a/packages/ui/src/__tests__/sidebar-footer.test.tsx b/packages/ui/src/__tests__/sidebar-footer.test.tsx index fb8cba1bb9..bf94ea58d1 100644 --- a/packages/ui/src/__tests__/sidebar-footer.test.tsx +++ b/packages/ui/src/__tests__/sidebar-footer.test.tsx @@ -29,6 +29,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { LocaleProvider } from '../locale-context.js'; import { SessionRailProvider, type SessionRailChrome } from '../session-rail-context.js'; import { SessionSidebarFooter } from '../session-sidebar-nav.js'; +import { SidebarUpdateProjectionProvider } from '../sidebar-update-projection-context.js'; function renderFooter( updateReminder?: { state: 'downloaded' | 'error'; latestVersion: string }, @@ -45,14 +46,19 @@ function renderFooter( onSelect: () => undefined, onNew: () => undefined, onOpenSettings: () => undefined, - onOpenUpdate: () => undefined, - updateReminder, }; return renderToStaticMarkup( - undefined }} chrome={chrome}> - - + undefined : undefined, + }} + > + undefined }} chrome={chrome}> + + + , ); } diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index f528ca84c2..4c8d2ffe35 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -38,6 +38,11 @@ export type { SessionViewMode, } from './session-rail-context.js'; export type { SidebarUpdateReminder } from './session-sidebar-nav.js'; +export { + SidebarUpdateProjectionProvider, + useSidebarUpdateProjection, + type SidebarUpdateProjection, +} from './sidebar-update-projection-context.js'; export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js'; export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolDisplayName } from './tool-format.js'; export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js'; diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx index 39e90ad5cb..011cc5b8c3 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -27,7 +27,6 @@ import type { SessionHistoryGroup, SessionRowActions, } from './session-history-list.js'; -import type { SidebarUpdateReminder } from './session-sidebar-nav.js'; export type SessionViewMode = 'conversation' | 'project'; @@ -65,10 +64,11 @@ export interface SessionRailData { * the column's own geometry. * * Deliberately a SECOND context rather than more fields on `SessionRailData`. - * These follow the shell — which section is selected, whether an update is - * waiting — and they change far more often than the list does, while costing a - * few dozen fibers against the list's thousand. Splitting them is what lets the - * chrome follow the shell without dragging the list with it. + * These follow the shell — which section is selected, which layout is active — + * and they change far more often than the list does, while costing a few dozen + * fibers against the list's thousand. Splitting them is what lets the chrome + * follow the shell without dragging the list with it. App Update has its own + * footer-only projection because download progress is independent of both. */ export interface SessionRailChrome { collapsed: boolean; @@ -86,8 +86,6 @@ export interface SessionRailChrome { onSelect(selection: NavSelection): void; onNew(): void; onOpenSettings(): void; - updateReminder?: SidebarUpdateReminder; - onOpenUpdate?(): void; workHubEntry?: { active: boolean; label: string; diff --git a/packages/ui/src/session-sidebar-nav.tsx b/packages/ui/src/session-sidebar-nav.tsx index 24bf9af3fa..a5a3c9a9aa 100644 --- a/packages/ui/src/session-sidebar-nav.tsx +++ b/packages/ui/src/session-sidebar-nav.tsx @@ -19,6 +19,7 @@ import { AlertCircle, Blocks, Download, Network, Settings, SquarePen, Timer } from './icons.js'; import { useSessionRailChrome } from './session-rail-context.js'; +import { useSidebarUpdateProjection } from './sidebar-update-projection-context.js'; import { useUiLocale } from './locale-context.js'; import { getShellControlsCopy } from './shell-controls-copy.js'; import { PlatformShortcutText } from './platform-shortcut-text.js'; @@ -106,8 +107,8 @@ export function SessionSidebarNav() { * * The updater runs with `autoDownload = true` and `autoInstallOnAppQuit = * false` (app-update-service.ts), so discovery and download ask nothing of - * anyone — the shell drops `available` and `downloading` before they reach - * here rather than the footer rendering a control for them. The old chip sat + * anyone — the App Update projection drops `available` and `downloading` + * before they reach here rather than the footer rendering a control for them. The old chip sat * in the footer through that whole silent phase counting bytes at someone who * had nothing to decide. */ @@ -118,10 +119,11 @@ export type SidebarUpdateReminder = { export function SessionSidebarFooter() { const props = useSessionRailChrome(); + const update = useSidebarUpdateProjection(); const locale = useUiLocale(); const copy = getShellControlsCopy(locale).navigation; - const reminder = props.updateReminder; - const updateAction = reminder && props.onOpenUpdate + const reminder = update.reminder; + const updateAction = reminder && update.onOpenUpdate ? { // One sentence, serving as both the tooltip and the accessible name. // The button carries no visible text, so a bare verb ("Restart") @@ -142,7 +144,7 @@ export function SessionSidebarFooter() { // downward arrow is the convention every app store made for exactly // this moment. icon: reminder.state === 'downloaded' ? Download : AlertCircle, - onClick: props.onOpenUpdate, + onClick: update.onOpenUpdate, } : undefined; diff --git a/packages/ui/src/sidebar-update-projection-context.ts b/packages/ui/src/sidebar-update-projection-context.ts new file mode 100644 index 0000000000..58d982d499 --- /dev/null +++ b/packages/ui/src/sidebar-update-projection-context.ts @@ -0,0 +1,43 @@ +/* + * 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, useContext } from 'react'; +import type { SidebarUpdateReminder } from './session-sidebar-nav.js'; + +/** The updater-owned projection consumed only by the sidebar footer. */ +export interface SidebarUpdateProjection { + readonly reminder: SidebarUpdateReminder | undefined; + readonly onOpenUpdate: (() => void) | undefined; +} + +const inactiveSidebarUpdateProjection: SidebarUpdateProjection = { + reminder: undefined, + onOpenUpdate: undefined, +}; + +const SidebarUpdateProjectionContext = createContext( + inactiveSidebarUpdateProjection, +); + +export const SidebarUpdateProjectionProvider = SidebarUpdateProjectionContext.Provider; + +/** Defaults to no reminder for Session rails hosted outside Desktop. */ +export function useSidebarUpdateProjection(): SidebarUpdateProjection { + return useContext(SidebarUpdateProjectionContext); +} diff --git a/packages/ui/stories/session-rail-harness.tsx b/packages/ui/stories/session-rail-harness.tsx index d3da37b9bf..bdbcfb2bdd 100644 --- a/packages/ui/stories/session-rail-harness.tsx +++ b/packages/ui/stories/session-rail-harness.tsx @@ -24,6 +24,10 @@ import { type SessionRailData, type SessionRailSelection, } from '../src/session-rail-context.js'; +import { + SidebarUpdateProjectionProvider, + type SidebarUpdateProjection, +} from '../src/sidebar-update-projection-context.js'; export type SessionRailStoryProps = Partial & Partial & @@ -35,6 +39,8 @@ export type SessionRailStoryProps = Partial & * already the shell's NavSelection. */ railSelection?: SessionRailSelection; + updateReminder?: SidebarUpdateProjection['reminder']; + onOpenUpdate?: SidebarUpdateProjection['onOpenUpdate']; }; /** @@ -75,17 +81,19 @@ export function SessionRail(props: SessionRailStoryProps) { onSelect: props.onSelect ?? (() => undefined), onNew: props.onNew ?? (() => undefined), onOpenSettings: props.onOpenSettings ?? (() => undefined), - updateReminder: props.updateReminder, - onOpenUpdate: props.onOpenUpdate, workHubEntry: props.workHubEntry, }; return ( - - - + + + + ); } diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 4ec98cf595..00e9afad00 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -118,7 +118,7 @@ export const ALLOWED = { useAppShellTurnPresentation: 1, useCommandPalette: 1, useComposerAttachments: 1, - useEffect: 14, + useEffect: 12, useKeyboardHelp: 1, useLayoutEffect: 2, useNewTaskChoice: 1, @@ -147,7 +147,7 @@ export const ALLOWED = { useShellRunUpdates: 1, useShellSearch: 1, useStableActions: 6, - useState: 15, + useState: 14, useTaskSubmissionReadiness: 1, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry