diff --git a/.project-docs/30-worklog/tasks/20260813-makelore-updater-client-7d3a9c.md b/.project-docs/30-worklog/tasks/20260813-makelore-updater-client-7d3a9c.md new file mode 100644 index 0000000..3cc8fcc --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260813-makelore-updater-client-7d3a9c.md @@ -0,0 +1,130 @@ +# Task: Fix Makelore updater diagnostics and release checks + +## Identity + +- Task ID: 20260813-makelore-updater-client-7d3a9c +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: a03043d048826609cb4f8cb7d782bde93e0e77f4 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Correlate the packaged Windows updater failure with the Works Square stable + feed and current platform/architecture resolution. +- Normalize the known missing stable-feed failure into a concise actionable + renderer-facing message while retaining raw diagnostic logging. +- Add focused updater regression coverage without changing download, install, + channel, or credential ownership behavior. +- Remove the duplicate renderer presentation shown by the installed `v1.0.0` + settings screenshot and keep raw stack diagnostics out of the ordinary user + surface while retaining them in Main-process logs. + +## Intent And Constraints + +- Electron Main remains the sole owner of update feed selection and + electron-updater integration. +- A missing stable updater artifact remains an error/release-readiness problem; + it must not be mislabeled as "already latest". +- Unexpected errors must retain their original messages, and no installer-role + or cross-architecture fallback may be introduced. +- Production recovery requires a separately published updater artifact; client + messaging alone does not repair the update chain. +- The currently installed `v1.0.0` is evidence of the old packaged behavior; + source changes do not affect it until a new client is built and installed. + +## Outcome + +- Confirmed the packaged Windows client resolves the supported stable target to + `https://square.nianxx.cn/api/app-updates/windows/x64` and that its local log + contains the reported `latest.yml` `HttpError: 404`. The corresponding public + feed currently returns the intentional server-side not-available response. +- Added a narrow renderer-facing normalization for missing Works Square stable + manifests on both Windows (`latest.yml`) and macOS (`latest-mac.yml`). The + concise Chinese message identifies an unpublished platform package and points + to retry/website download without misreporting the client as up to date. +- Raw errors remain logged, emitted, and rethrown; updater status remains + `error`; prerelease OSS feeds and unrelated errors are unchanged. +- Confirmed the screenshot's duplicated output came from one Zustand `error` + value rendered both in the status row and a second error-details card. The + settings page now renders one concise message, hides technical stacks and + URLs behind the existing generic failure copy, and keeps the retry action. +- De-duplicated the Main-process `error` event plus rejected-promise path by + per-invocation check tokens. Concurrent callers sharing one electron-updater + error now produce one renderer error status, while raw logging/rethrow and + later independent retries remain intact. +- Hardened the settings boundary so only concise Chinese user-facing prose is + shown directly. Raw English diagnostics, stacks, URLs, paths, error codes and + structured payloads fall back to the localized generic failure message. +- Confirmed the installed `v1.0.0` `app.asar` predates these source changes. + Built an isolated unsigned `v2.0.0` `win-unpacked` artifact under the user's + temporary directory without replacing the installed application or the + repository's existing `release/` output. +- No release was published. Current evidence shows website installer `0.9.2`, + installed client `1.0.0`, source version `2.0.0`, and unsigned local Windows + artifacts, so production recovery still requires release-owner action. + +## Verification + +- Regression tests were observed red before each Windows/macOS normalization + change and green afterward. +- `pnpm exec vitest run tests/unit/app-updater.test.ts + tests/unit/update-settings.test.tsx tests/unit/update-store.test.ts + tests/unit/sidebar-update-button.test.tsx`: initially `30 passed` across four + files; the final rerun after review fixes is recorded below. +- `pnpm exec eslint electron/main/updater.ts + src/components/settings/UpdateSettings.tsx + tests/unit/app-updater.test.ts tests/unit/update-settings.test.tsx`: exit + `0`, no findings. +- `pnpm run typecheck`: exit `0`. +- `pnpm run build:vite`: exit `0`; the production Main bundle contains the + normalized Chinese message. +- Isolated `electron-builder --dir` packaging: exit `0`; extracted `app.asar` + reports version `2.0.0`, contains the Main normalization and renderer + sanitizer, and no longer contains the `updates.errorDetails` render key. +- `pnpm test`: updater-related tests pass, but the full suite remains red with + three failures outside the changed updater scope: missing `.opencode/agent` + in `youth-plain-language-skill.test.ts`, plus two + `opencode-manager.test.ts` timing/generation failures. Focused reruns + reproduced the missing-directory failure and one generation failure; no + base-commit run was performed, so they are recorded without attributing their + origin to this task. +- Read-only Authenticode checks report `NotSigned` for the available local + Windows artifacts, including the isolated artifact; none were installed or + uploaded. +- Initial independent read-only review: FAIL with two medium findings covering + concurrent duplicate suppression and denylist-only renderer sanitization. + Both findings were corrected with focused regression tests before final + review. +- Final focused rerun after those corrections: 4 files / 37 tests passed; + typecheck, scoped ESLint, `build:vite`, `git diff --check`, and + `check_doc_drift.py --task-id 20260813-makelore-updater-client-7d3a9c` all + passed. +- Final independent read-only review: PASS with no remaining implementation, + architecture, or test-coverage finding. + +## Follow-ups + +- Align the authoritative release version, generate final signed/notarized + platform artifacts, publish them to Works Square as updater-role records, and + execute discover/download/restart/install smoke tests from older packages. +- Add a release job that publishes signed stable outputs to Works Square rather + than only the legacy OSS channel, with inactive-first verification and an + explicit activation policy. +- Harden the stable Windows signing job by verifying Authenticode status and + expected publisher after SignPath. Enabling publisher pinning in new clients + requires a staged rollout after the stable feed always serves signed packages. +- Repair the missing `.opencode/agent` fixture and independently diagnose the + two existing opencode-manager concurrency failures before treating the full + test suite as a release gate. + +## Promotion Candidates + +- After live proof, promote the stable-feed error behavior and release pipeline + requirements to canonical updater/release documentation; keep the external + release gate open until then. +- Consider promoting the UI contract that ordinary updater errors are concise, + non-duplicated, and free of raw stack/URL details; technical diagnostics stay + in Main logs. diff --git a/electron/main/updater.ts b/electron/main/updater.ts index c8868ec..78a2f87 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -14,6 +14,18 @@ import { setQuitting } from './app-state'; /** Base CDN URL (without trailing channel path) */ const OSS_BASE_URL = 'https://oss.intelli-spectrum.com'; const WORKS_SQUARE_UPDATE_BASE_URL = 'https://square.nianxx.cn/api/app-updates'; +const STABLE_UPDATE_NOT_PUBLISHED_ERROR = '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版'; + +export function normalizeUpdateError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const isMissingStableChannel = message.includes('Cannot find channel "latest.yml" update info') + || message.includes('Cannot find channel "latest-mac.yml" update info'); + const isMissingStableManifest = isMissingStableChannel + && message.includes('HttpError: 404') + && message.includes(WORKS_SQUARE_UPDATE_BASE_URL); + + return isMissingStableManifest ? STABLE_UPDATE_NOT_PUBLISHED_ERROR : message; +} export interface UpdateStatus { status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; @@ -69,6 +81,8 @@ export class AppUpdater extends EventEmitter { private autoInstallTimer: NodeJS.Timeout | null = null; private autoInstallCountdown = 0; private readonly updateFeedError: string | null; + private readonly activeUpdateChecks = new Set(); + private readonly updaterEventErrorChecks = new Map>(); /** Delay (in seconds) before auto-installing a downloaded update. */ private static readonly AUTO_INSTALL_DELAY_SECONDS = 5; @@ -162,7 +176,10 @@ export class AppUpdater extends EventEmitter { }); autoUpdater.on('error', (error: Error) => { - this.updateStatus({ status: 'error', error: error.message }); + if (this.activeUpdateChecks.size > 0) { + this.updaterEventErrorChecks.set(error, new Set(this.activeUpdateChecks)); + } + this.updateStatus({ status: 'error', error: normalizeUpdateError(error) }); this.emit('error', error); }); } @@ -198,6 +215,8 @@ export class AppUpdater extends EventEmitter { * final status so the UI never gets stuck in 'checking'. */ async checkForUpdates(): Promise { + const checkToken = Symbol('update-check'); + this.activeUpdateChecks.add(checkToken); try { this.updateStatus({ status: 'checking' }); @@ -230,8 +249,22 @@ export class AppUpdater extends EventEmitter { return result.updateInfo || null; } catch (error) { logger.error('[Updater] Check for updates failed:', error); - this.updateStatus({ status: 'error', error: (error as Error).message || String(error) }); + const reportedChecks = error instanceof Error + ? this.updaterEventErrorChecks.get(error) + : undefined; + const alreadyReported = reportedChecks?.has(checkToken) ?? false; + if (!alreadyReported) { + this.updateStatus({ status: 'error', error: normalizeUpdateError(error) }); + } throw error; + } finally { + this.activeUpdateChecks.delete(checkToken); + for (const [error, reportedChecks] of this.updaterEventErrorChecks) { + reportedChecks.delete(checkToken); + if (reportedChecks.size === 0) { + this.updaterEventErrorChecks.delete(error); + } + } } } diff --git a/src/components/settings/UpdateSettings.tsx b/src/components/settings/UpdateSettings.tsx index 750c66d..3f9600d 100644 --- a/src/components/settings/UpdateSettings.tsx +++ b/src/components/settings/UpdateSettings.tsx @@ -17,6 +17,20 @@ function formatBytes(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } +function formatUpdateError(error: string | null, fallback: string): string { + if (!error) return fallback; + + const firstLine = error.replace(/\\r\\n|\\n|\r\n|\n/g, '\n').split('\n', 1)[0].trim(); + const isTechnical = + /(?:^\w*Error\s*:|HttpError|Cannot find channel|node_modules|https?:\/\/|\bnet::ERR_[A-Z0-9_]+\b|\bE(?:ACCES|CONNREFUSED|CONNRESET|EXIST|HOSTUNREACH|ISDIR|NETUNREACH|NOENT|NOTDIR|PERM|PIPE|TIMEDOUT)\b|\b(?:ERR|UPDATER)_[A-Z0-9_]+\b|[A-Z]:\\|\\\\[^\\]|\bat\s+\S+\s*\()/i.test( + firstLine, + ) || /^[{[]/.test(firstLine); + const isUserFacingProse = /\p{Script=Han}/u.test(firstLine); + + if (!firstLine || firstLine.length > 180 || isTechnical || !isUserFacingProse) return fallback; + return firstLine; +} + export function UpdateSettings() { const { t } = useTranslation('settings'); const { @@ -75,7 +89,7 @@ export function UpdateSettings() { case 'downloaded': return t('updates.status.downloaded', { version: updateInfo?.version }); case 'error': - return error || t('updates.status.failed'); + return formatUpdateError(error, t('updates.status.failed')); case 'not-available': return t('updates.status.latest'); default: @@ -160,7 +174,9 @@ export function UpdateSettings() { {/* Status */}
-

{renderStatusText()}

+

+ {renderStatusText()} +

{renderAction()}
@@ -200,14 +216,6 @@ export function UpdateSettings() { )} - {/* Error Details */} - {status === 'error' && error && ( -
-

{t('updates.errorDetails')}

-

{error}

-
- )} - {/* Help Text */}

{t('updates.help')} diff --git a/tests/unit/app-updater.test.ts b/tests/unit/app-updater.test.ts index 125bf24..36ba17d 100644 --- a/tests/unit/app-updater.test.ts +++ b/tests/unit/app-updater.test.ts @@ -5,6 +5,10 @@ const electronMocks = vi.hoisted(() => ({ ipcHandle: vi.fn(), })); +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), +})); + const updaterMocks = vi.hoisted(() => { const listeners = new Map void>>(); const autoUpdater = { @@ -41,7 +45,7 @@ vi.mock('@electron/utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), - error: vi.fn(), + error: loggerMocks.error, debug: vi.fn(), }, })); @@ -127,6 +131,97 @@ describe('AppUpdater feed delegation', () => { } }); + it('reports a concise actionable error when the stable feed has no latest manifest', async () => { + const rawError = new Error( + 'Cannot find channel "latest.yml" update info: HttpError: 404\n' + + 'GET https://square.nianxx.cn/api/app-updates/windows/x64/latest.yml', + ); + updaterMocks.autoUpdater.checkForUpdates.mockRejectedValue(rawError); + const updater = new AppUpdater(); + const rendererSend = vi.fn(); + updater.setMainWindow({ + isDestroyed: () => false, + webContents: { send: rendererSend }, + } as unknown as Parameters[0]); + + await expect(updater.checkForUpdates()).rejects.toBe(rawError); + + expect(updater.getStatus()).toMatchObject({ + status: 'error', + error: '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版', + }); + expect(rendererSend.mock.calls.filter(([channel, status]) => ( + channel === 'update:status-changed' && status?.status === 'error' + ))).toHaveLength(1); + }); + + it('reports one error status when electron-updater emits and rejects the same check error', async () => { + const rawError = new Error( + 'Cannot find channel "latest.yml" update info: HttpError: 404\n' + + 'GET https://square.nianxx.cn/api/app-updates/windows/x64/latest.yml', + ); + const updater = new AppUpdater(); + const errorListener = vi.fn(); + const rendererSend = vi.fn(); + updater.on('error', errorListener); + updater.setMainWindow({ + isDestroyed: () => false, + webContents: { send: rendererSend }, + } as unknown as Parameters[0]); + let rejectSharedCheck!: (error: Error) => void; + const sharedCheck = new Promise((_resolve, reject) => { + rejectSharedCheck = reject; + }); + updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(() => sharedCheck); + updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(() => sharedCheck); + updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(async () => { + const updaterErrorListeners = updaterMocks.listeners.get('error') ?? []; + updaterErrorListeners.at(-1)?.(rawError); + throw rawError; + }); + + const firstCheck = updater.checkForUpdates(); + const concurrentCheck = updater.checkForUpdates(); + const updaterErrorListeners = updaterMocks.listeners.get('error') ?? []; + updaterErrorListeners.at(-1)?.(rawError); + rejectSharedCheck(rawError); + + await expect(Promise.allSettled([firstCheck, concurrentCheck])).resolves.toEqual([ + { status: 'rejected', reason: rawError }, + { status: 'rejected', reason: rawError }, + ]); + + expect(rendererSend.mock.calls.filter(([channel, status]) => ( + channel === 'update:status-changed' && status?.status === 'error' + ))).toHaveLength(1); + expect(errorListener).toHaveBeenCalledOnce(); + expect(errorListener).toHaveBeenCalledWith(rawError); + expect(loggerMocks.error).toHaveBeenCalledWith('[Updater] Check for updates failed:', rawError); + + await expect(updater.checkForUpdates()).rejects.toBe(rawError); + + expect(rendererSend.mock.calls.filter(([channel, status]) => ( + channel === 'update:status-changed' && status?.status === 'error' + ))).toHaveLength(2); + expect(errorListener).toHaveBeenCalledTimes(2); + }); + + it('reports the same actionable error when the macOS stable feed has no latest manifest', async () => { + const rawError = new Error( + 'Cannot find channel "latest-mac.yml" update info: HttpError: 404\n' + + 'GET https://square.nianxx.cn/api/app-updates/mac/arm64/latest-mac.yml', + ); + updaterMocks.autoUpdater.checkForUpdates.mockRejectedValue(rawError); + const updater = new AppUpdater(); + + await expect(updater.checkForUpdates()).rejects.toBe(rawError); + + expect(updater.getStatus()).toMatchObject({ + status: 'error', + error: '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版', + }); + }); + it('keeps prerelease channels on their existing electron-updater feed', async () => { electronMocks.getVersion.mockReturnValue('0.9.2-beta.1'); updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue({ diff --git a/tests/unit/update-settings.test.tsx b/tests/unit/update-settings.test.tsx new file mode 100644 index 0000000..963e9b4 --- /dev/null +++ b/tests/unit/update-settings.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UpdateSettings } from '@/components/settings/UpdateSettings'; +import { useUpdateStore } from '@/stores/update'; + +const actionableMessage = + '\u5f53\u524d\u5e73\u53f0\u7684\u6b63\u5f0f\u66f4\u65b0\u5305\u5c1a\u672a\u53d1\u5e03\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002'; + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => + ({ + 'updates.currentVersion': 'Current version', + 'updates.status.failed': 'Generic update failure', + 'updates.action.retry': 'Retry', + 'updates.help': 'Keep Makelore updated', + })[key] ?? key, + }), + }; +}); + +describe('UpdateSettings update errors', () => { + const init = vi.fn().mockResolvedValue(undefined); + const checkForUpdates = vi.fn().mockResolvedValue(undefined); + const clearError = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + useUpdateStore.setState({ + status: 'error', + currentVersion: '1.0.0', + updateInfo: null, + progress: null, + error: actionableMessage, + isInitialized: true, + isInitializing: false, + autoInstallCountdown: null, + init, + checkForUpdates, + clearError, + }); + }); + + it('shows a concise updater error only once', () => { + render(); + + expect(screen.getAllByText(actionableMessage)).toHaveLength(1); + }); + + it.each([ + 'Cannot find channel "latest.yml" update info: HttpError: 404\r\nPlease double check authentication\r\n at ElectronHttpExecutor.handleResponse (app.asar/node_modules/builder-util-runtime/out/httpExecutor.js:121:20)', + 'Cannot find channel "latest.yml" update info: HttpError: 404\\r\\nPlease double check authentication\\n at ElectronHttpExecutor.handleResponse (app.asar/node_modules/builder-util-runtime/out/httpExecutor.js:121:20)', + ])('does not expose updater stack details for a long technical error', (error) => { + useUpdateStore.setState({ error }); + + render(); + + expect(screen.getByText('Generic update failure')).toBeInTheDocument(); + expect(screen.queryByText(/ElectronHttpExecutor|node_modules|Please double check/)).not.toBeInTheDocument(); + expect(screen.queryByText('updates.errorDetails')).not.toBeInTheDocument(); + }); + + it.each([ + 'net::ERR_NAME_NOT_RESOLVED', + 'Error: Update check failed', + 'Update check temporarily unavailable', + "ENOENT: no such file or directory, open 'C:\\Users\\tester\\AppData\\Local\\Makelore\\latest.yml'", + 'ERR_UPDATER_INVALID_RELEASE_FEED', + '{"code":"ERR_UPDATER_INVALID_RELEASE_FEED","message":"Update check failed"}', + ])('falls back for a short technical updater diagnostic: %s', (error) => { + useUpdateStore.setState({ error }); + + render(); + + expect(screen.getByText('Generic update failure')).toBeInTheDocument(); + expect(screen.queryByText(error)).not.toBeInTheDocument(); + }); + + it('keeps concise actionable Chinese prose', () => { + render(); + + expect(screen.getByText(actionableMessage)).toBeInTheDocument(); + }); + + it('initializes on mount and retries through the existing store action', async () => { + render(); + + await waitFor(() => expect(init).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + await waitFor(() => expect(checkForUpdates).toHaveBeenCalledTimes(1)); + expect(clearError).toHaveBeenCalledTimes(1); + }); +});