修复客户端更新诊断与错误展示

This commit is contained in:
2026-08-13 15:28:14 +08:00
parent a03043d048
commit f05b9d4cb5
5 changed files with 376 additions and 13 deletions

View File

@@ -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.

View File

@@ -14,6 +14,18 @@ import { setQuitting } from './app-state';
/** Base CDN URL (without trailing channel path) */ /** Base CDN URL (without trailing channel path) */
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com'; const OSS_BASE_URL = 'https://oss.intelli-spectrum.com';
const WORKS_SQUARE_UPDATE_BASE_URL = 'https://square.nianxx.cn/api/app-updates'; 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 { export interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; 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 autoInstallTimer: NodeJS.Timeout | null = null;
private autoInstallCountdown = 0; private autoInstallCountdown = 0;
private readonly updateFeedError: string | null; private readonly updateFeedError: string | null;
private readonly activeUpdateChecks = new Set<symbol>();
private readonly updaterEventErrorChecks = new Map<Error, Set<symbol>>();
/** Delay (in seconds) before auto-installing a downloaded update. */ /** Delay (in seconds) before auto-installing a downloaded update. */
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5; private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
@@ -162,7 +176,10 @@ export class AppUpdater extends EventEmitter {
}); });
autoUpdater.on('error', (error: Error) => { 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); this.emit('error', error);
}); });
} }
@@ -198,6 +215,8 @@ export class AppUpdater extends EventEmitter {
* final status so the UI never gets stuck in 'checking'. * final status so the UI never gets stuck in 'checking'.
*/ */
async checkForUpdates(): Promise<UpdateInfo | null> { async checkForUpdates(): Promise<UpdateInfo | null> {
const checkToken = Symbol('update-check');
this.activeUpdateChecks.add(checkToken);
try { try {
this.updateStatus({ status: 'checking' }); this.updateStatus({ status: 'checking' });
@@ -230,8 +249,22 @@ export class AppUpdater extends EventEmitter {
return result.updateInfo || null; return result.updateInfo || null;
} catch (error) { } catch (error) {
logger.error('[Updater] Check for updates failed:', 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; 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);
}
}
} }
} }

View File

@@ -17,6 +17,20 @@ function formatBytes(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 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() { export function UpdateSettings() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const { const {
@@ -75,7 +89,7 @@ export function UpdateSettings() {
case 'downloaded': case 'downloaded':
return t('updates.status.downloaded', { version: updateInfo?.version }); return t('updates.status.downloaded', { version: updateInfo?.version });
case 'error': case 'error':
return error || t('updates.status.failed'); return formatUpdateError(error, t('updates.status.failed'));
case 'not-available': case 'not-available':
return t('updates.status.latest'); return t('updates.status.latest');
default: default:
@@ -160,7 +174,9 @@ export function UpdateSettings() {
{/* Status */} {/* Status */}
<div className="flex items-center justify-between py-3 border-t border-b"> <div className="flex items-center justify-between py-3 border-t border-b">
<p className="text-sm text-muted-foreground">{renderStatusText()}</p> <p className="min-w-0 flex-1 break-words pr-3 text-sm text-muted-foreground">
{renderStatusText()}
</p>
{renderAction()} {renderAction()}
</div> </div>
@@ -200,14 +216,6 @@ export function UpdateSettings() {
</div> </div>
)} )}
{/* Error Details */}
{status === 'error' && error && (
<div className="rounded-lg bg-red-50 bg-red-500/10 p-4 text-red-600 text-red-700 text-sm">
<p className="font-medium mb-1">{t('updates.errorDetails')}</p>
<p>{error}</p>
</div>
)}
{/* Help Text */} {/* Help Text */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('updates.help')} {t('updates.help')}

View File

@@ -5,6 +5,10 @@ const electronMocks = vi.hoisted(() => ({
ipcHandle: vi.fn(), ipcHandle: vi.fn(),
})); }));
const loggerMocks = vi.hoisted(() => ({
error: vi.fn(),
}));
const updaterMocks = vi.hoisted(() => { const updaterMocks = vi.hoisted(() => {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>(); const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const autoUpdater = { const autoUpdater = {
@@ -41,7 +45,7 @@ vi.mock('@electron/utils/logger', () => ({
logger: { logger: {
info: vi.fn(), info: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
error: vi.fn(), error: loggerMocks.error,
debug: vi.fn(), 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<AppUpdater['setMainWindow']>[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<AppUpdater['setMainWindow']>[0]);
let rejectSharedCheck!: (error: Error) => void;
const sharedCheck = new Promise<never>((_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 () => { it('keeps prerelease channels on their existing electron-updater feed', async () => {
electronMocks.getVersion.mockReturnValue('0.9.2-beta.1'); electronMocks.getVersion.mockReturnValue('0.9.2-beta.1');
updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue({ updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue({

View File

@@ -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<typeof import('react-i18next')>();
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(<UpdateSettings />);
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(<UpdateSettings />);
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(<UpdateSettings />);
expect(screen.getByText('Generic update failure')).toBeInTheDocument();
expect(screen.queryByText(error)).not.toBeInTheDocument();
});
it('keeps concise actionable Chinese prose', () => {
render(<UpdateSettings />);
expect(screen.getByText(actionableMessage)).toBeInTheDocument();
});
it('initializes on mount and retries through the existing store action', async () => {
render(<UpdateSettings />);
await waitFor(() => expect(init).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(checkForUpdates).toHaveBeenCalledTimes(1));
expect(clearError).toHaveBeenCalledTimes(1);
});
});