🐛 fix(desktop): gracefully handle missing update manifest 404 errors (#11625)

- Add isMissingUpdateManifestError helper to detect manifest 404 errors
- Treat missing manifest as "no update available" during gap period
- Fix sidebar header margin for desktop layout
This commit is contained in:
Innei
2026-01-20 23:41:34 +08:00
committed by GitHub
parent 01550e0b13
commit 13e95b98c2
3 changed files with 96 additions and 3 deletions

View File

@@ -1,3 +1,5 @@
import type { UpdateInfo } from '@lobechat/electron-client-ipc';
import { app as electronApp } from 'electron';
import log from 'electron-log';
import { autoUpdater } from 'electron-updater';
@@ -120,10 +122,22 @@ export class UpdaterManager {
try {
await autoUpdater.checkForUpdates();
} catch (error) {
logger.error('Error checking for updates:', error.message);
const message = error instanceof Error ? error.message : String(error);
// Edge case: Release tag exists but update manifest assets (latest/stable-*.yml) aren't uploaded yet.
// Treat this gap period as "no updates available" instead of a user-facing error.
if (this.isMissingUpdateManifestError(error)) {
logger.warn('[Updater] Update manifest not ready yet, treating as no update:', message);
if (manual) {
this.mainWindow.broadcast('manualUpdateNotAvailable', this.getCurrentUpdateInfo());
}
return;
}
logger.error('Error checking for updates:', message);
if (manual) {
this.mainWindow.broadcast('updateError', (error as Error).message);
this.mainWindow.broadcast('updateError', message);
}
} finally {
this.checking = false;
@@ -397,6 +411,18 @@ export class UpdaterManager {
});
autoUpdater.on('error', async (err) => {
const message = err instanceof Error ? err.message : String(err);
// Edge case: Release tag exists but update manifest assets aren't uploaded yet.
// Skip fallback switching and avoid user-facing errors.
if (this.isMissingUpdateManifestError(err)) {
logger.warn('[Updater] Update manifest not ready yet, skipping error handling:', message);
if (this.isManualCheck) {
this.mainWindow.broadcast('manualUpdateNotAvailable', this.getCurrentUpdateInfo());
}
return;
}
logger.error('Error in auto-updater:', err);
logger.error('[Updater Error Context] Channel:', autoUpdater.channel);
logger.error('[Updater Error Context] allowPrerelease:', autoUpdater.allowPrerelease);
@@ -436,4 +462,28 @@ export class UpdaterManager {
logger.debug('Updater events registered');
}
private isMissingUpdateManifestError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '');
if (!message) return false;
// Expect patterns like:
// - "Cannot find latest-mac.yml ... HttpError: 404 ..."
// - "Cannot find stable.yml ... 404 ..."
if (!/cannot find/i.test(message)) return false;
if (!/\b404\b/.test(message)) return false;
// Match channel manifest filenames across platforms/architectures:
// latest.yml, latest-mac.yml, latest-linux.yml, stable.yml, stable-mac.yml, etc.
const manifestMatch = message.match(/\b(?:latest|stable)(?:-[\da-z]+)?\.yml\b/i);
return Boolean(manifestMatch);
}
private getCurrentUpdateInfo(): UpdateInfo {
const version = autoUpdater.currentVersion?.version || electronApp.getVersion();
return {
releaseDate: new Date().toISOString(),
version,
};
}
}

View File

@@ -31,6 +31,7 @@ vi.mock('electron-updater', () => ({
autoInstallOnAppQuit: false,
channel: 'stable',
checkForUpdates: vi.fn(),
currentVersion: undefined as any,
downloadUpdate: vi.fn(),
forceDevUpdateConfig: false,
logger: null as any,
@@ -46,6 +47,7 @@ vi.mock('electron', () => ({
getAllWindows: mockGetAllWindows,
},
app: {
getVersion: vi.fn().mockReturnValue('0.0.0'),
releaseSingleInstanceLock: mockReleaseSingleInstanceLock,
},
}));
@@ -108,6 +110,7 @@ describe('UpdaterManager', () => {
(autoUpdater as any).allowPrerelease = false;
(autoUpdater as any).allowDowngrade = false;
(autoUpdater as any).forceDevUpdateConfig = false;
(autoUpdater as any).currentVersion = undefined;
// Capture registered events
registeredEvents = new Map();
@@ -212,6 +215,24 @@ describe('UpdaterManager', () => {
expect(mockBroadcast).toHaveBeenCalledWith('updateError', 'Network error');
});
it('should treat missing latest/stable yml 404 as not-available during manual check', async () => {
const error = new Error(
'Cannot find latest-mac.yml in the latest release artifacts (https://github.com/lobehub/lobe-chat/releases/download/v2.0.0-next.311/latest-mac.yml): HttpError: 404',
);
vi.mocked(autoUpdater.checkForUpdates).mockRejectedValueOnce(error);
await updaterManager.checkForUpdates({ manual: true });
expect(mockBroadcast).toHaveBeenCalledWith(
'manualUpdateNotAvailable',
expect.objectContaining({
releaseDate: expect.any(String),
version: expect.any(String),
}),
);
expect(mockBroadcast).not.toHaveBeenCalledWith('updateError', expect.anything());
});
});
describe('downloadUpdate', () => {
@@ -486,6 +507,26 @@ describe('UpdaterManager', () => {
expect(mockBroadcast).not.toHaveBeenCalledWith('updateError', expect.anything());
});
it('should not broadcast updateError for missing manifest 404 (gap period)', async () => {
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
await updaterManager.checkForUpdates({ manual: true });
const error = new Error(
'Cannot find latest-mac.yml in the latest release artifacts (https://github.com/lobehub/lobe-chat/releases/download/v2.0.0-next.311/latest-mac.yml): HttpError: 404',
);
const handler = registeredEvents.get('error');
await handler?.(error);
expect(mockBroadcast).toHaveBeenCalledWith(
'manualUpdateNotAvailable',
expect.objectContaining({
releaseDate: expect.any(String),
version: expect.any(String),
}),
);
expect(mockBroadcast).not.toHaveBeenCalledWith('updateError', expect.anything());
});
});
});

View File

@@ -8,6 +8,8 @@ import { type ReactNode, memo } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { isDesktop } from '@/const/version';
import ToggleLeftPanelButton from './ToggleLeftPanelButton';
import BackButton from './components/BackButton';
@@ -35,7 +37,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
container: css`
overflow: hidden;
margin-block-start: 8px;
margin-block-start: ${isDesktop ? '' : '8px'};
`,
}));