fix(electron): recover from incompatible data directory

This commit is contained in:
digua committed 2026-08-12 10:00:12 +08:00
1 parent e482a675a0
commit dfabc4f087
10 files changed
+325 -44

No files matched your search

+10 -1
View File
@@ -20,7 +20,8 @@ import {
} from '../paths/legacy-migration'
import { ensureAppDirs, getSystemDataDir, getAiDataDir, getTempDir } from '../paths/locations'
import { getPathProvider } from '../paths/provider'
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime/compat'
import { assertDesktopDataDirCompatible, getDesktopAppVersion, getDesktopUpdateRequirement } from '../runtime/compat'
import { recoverRequiredDesktopUpdate } from '../update/manager'
export async function prepareDesktopRuntime(isTestMode: boolean): Promise<boolean> {
logNativeParserStatus()
@@ -42,6 +43,14 @@ export async function prepareDesktopRuntime(isTestMode: boolean): Promise<boolea
runtime = assertDesktopDataDirCompatible(getPathProvider(), getDesktopAppVersion(app.getVersion()))
} catch (error) {
console.error('[Main] Data directory compatibility check failed:', error)
const updateRequirement = getDesktopUpdateRequirement(error)
if (updateRequirement && !isTestMode) {
await app.whenReady()
await initLocale()
await recoverRequiredDesktopUpdate(updateRequirement)
return false
}
dialog.showErrorBox(
'ChatLab Data Directory Incompatible',
`ChatLab cannot open this data directory with the current desktop version.\n\n${
+12
View File
@@ -30,6 +30,18 @@ export default {
remindLater: 'Remind Later',
installOnQuit: 'Later (auto-install on quit)',
upToDate: 'You are up to date',
requiredTitle: 'ChatLab Update Required',
requiredMessage: 'Version {{currentVersion}} cannot open this data directory',
requiredDetail:
'This data directory requires ChatLab {{minRuntimeVersion}} or newer. To protect your data, ChatLab will not open the database with the current version.\n\nAfter you choose Update Now, ChatLab downloads in the background and starts the installer when ready; the main window stays closed during the download. You can also open the official download page.\nData directory: {{userDataDir}}',
updateNow: 'Update Now',
openDownloadPage: 'Open Download Page',
quit: 'Quit',
requiredUpdateFailedTitle: 'Automatic Update Failed',
requiredUpdateFailedMessage: 'ChatLab could not update to the required version',
requiredUpdateFailedDetail:
'Install ChatLab {{minRuntimeVersion}} or newer from the official download page, then reopen the app.',
openDownloadFailed: 'Could not open the download page. Open this address manually:',
},
// ===== P0: File/directory dialogs =====
+12
View File
@@ -30,6 +30,18 @@ export default {
remindLater: '後で通知',
installOnQuit: '後で(アプリ終了時に自動インストール)',
upToDate: '最新バージョンです',
requiredTitle: 'ChatLab の更新が必要です',
requiredMessage: '現在のバージョン {{currentVersion}} では、このデータディレクトリを開けません',
requiredDetail:
'このデータディレクトリには ChatLab {{minRuntimeVersion}} 以降が必要です。データを保護するため、現在のバージョンではデータベースを開きません。\n\n「今すぐ更新」を選ぶとバックグラウンドでダウンロードし、完了後にインストーラーを起動します。ダウンロード中はメイン画面を開きません。公式ダウンロードページを開くこともできます。\nデータディレクトリ:{{userDataDir}}',
updateNow: '今すぐ更新',
openDownloadPage: 'ダウンロードページを開く',
quit: '終了',
requiredUpdateFailedTitle: '自動更新に失敗しました',
requiredUpdateFailedMessage: '必要なバージョンに更新できませんでした',
requiredUpdateFailedDetail:
'公式ダウンロードページから ChatLab {{minRuntimeVersion}} 以降をインストールして、アプリを再度開いてください。',
openDownloadFailed: 'ダウンロードページを開けませんでした。次の URL を手動で開いてください:',
},
// ===== P0: ファイル/ディレクトリダイアログ =====
+11
View File
@@ -29,6 +29,17 @@ export default {
remindLater: '之后提醒',
installOnQuit: '稍后(应用退出后自动安装)',
upToDate: '已是最新版本',
requiredTitle: '需要更新 ChatLab',
requiredMessage: '当前版本 {{currentVersion}} 无法打开此数据目录',
requiredDetail:
'此数据目录需要 ChatLab {{minRuntimeVersion}} 或更高版本。为保护数据,ChatLab 不会使用当前版本打开数据库。\n\n选择立即更新后,应用将在后台下载,完成后自动启动安装程序;下载期间主页面不会打开。你也可以前往官方下载页面。\n数据目录:{{userDataDir}}',
updateNow: '立即更新',
openDownloadPage: '打开下载页面',
quit: '退出',
requiredUpdateFailedTitle: '自动更新失败',
requiredUpdateFailedMessage: '无法自动升级到所需版本',
requiredUpdateFailedDetail: '请从官方下载页面安装 ChatLab {{minRuntimeVersion}} 或更高版本,然后重新打开应用。',
openDownloadFailed: '无法打开下载页面,请手动访问:',
},
// ===== P0: 文件/目录对话框 =====
+11
View File
@@ -30,6 +30,17 @@ export default {
remindLater: '稍後提醒',
installOnQuit: '稍後(應用退出後自動安裝)',
upToDate: '已是最新版本',
requiredTitle: '需要更新 ChatLab',
requiredMessage: '目前版本 {{currentVersion}} 無法開啟此資料目錄',
requiredDetail:
'此資料目錄需要 ChatLab {{minRuntimeVersion}} 或更新版本。為了保護資料,ChatLab 不會使用目前版本開啟資料庫。\n\n選擇立即更新後,應用程式會在背景下載,完成後自動啟動安裝程式;下載期間不會開啟主畫面。你也可以前往官方下載頁面。\n資料目錄:{{userDataDir}}',
updateNow: '立即更新',
openDownloadPage: '開啟下載頁面',
quit: '結束',
requiredUpdateFailedTitle: '自動更新失敗',
requiredUpdateFailedMessage: '無法自動升級到所需版本',
requiredUpdateFailedDetail: '請從官方下載頁面安裝 ChatLab {{minRuntimeVersion}} 或更新版本,然後重新開啟應用程式。',
openDownloadFailed: '無法開啟下載頁面,請手動前往:',
},
// ===== P0: 檔案/目錄對話框 =====
+13 -1
View File
@@ -4,7 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import { assertDesktopDataDirCompatible, resolveDesktopAppVersion } from './compat'
import { assertDesktopDataDirCompatible, getDesktopUpdateRequirement, resolveDesktopAppVersion } from './compat'
function makeTempDir(): string {
const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir())
@@ -94,6 +94,18 @@ test('assertDesktopDataDirCompatible formats a startup error for older desktop r
return true
}
)
let startupError: unknown
try {
assertDesktopDataDirCompatible(makePathProvider(userDataDir), '0.25.0')
} catch (error) {
startupError = error
}
assert.deepEqual(getDesktopUpdateRequirement(startupError), {
currentVersion: '0.25.0',
minRuntimeVersion: '999.0.0',
userDataDir,
})
})
test('assertDesktopDataDirCompatible shows original prerelease version when blocked', () => {
+24
View File
@@ -5,6 +5,12 @@ import {
type RuntimeIdentity,
} from '@openchatlab/node-runtime/data-dir-compat'
export interface DesktopUpdateRequirement {
currentVersion: string
minRuntimeVersion: string
userDataDir: string
}
export function resolveDesktopAppVersion(electronVersion: string | null | undefined, bundledVersion?: string): string {
const normalizedElectronVersion = normalizeVersion(electronVersion)
if (normalizedElectronVersion && normalizedElectronVersion !== '0.0.0') return normalizedElectronVersion
@@ -43,6 +49,24 @@ export function assertDesktopDataDirCompatible(pathProvider: PathProvider, versi
return runtime
}
export function getDesktopUpdateRequirement(error: unknown): DesktopUpdateRequirement | null {
const cause = error instanceof Error ? error.cause : undefined
if (
!(cause instanceof DataDirCompatibilityError) ||
cause.code !== 'DATA_DIR_REQUIRES_NEWER_RUNTIME' ||
!cause.currentVersion ||
!cause.minRuntimeVersion
) {
return null
}
return {
currentVersion: cause.currentVersion,
minRuntimeVersion: cause.minRuntimeVersion,
userDataDir: cause.userDataDir,
}
}
function normalizeVersion(version: string | null | undefined): string {
return typeof version === 'string' ? version.trim() : ''
}
+109 -33
View File
@@ -9,13 +9,22 @@ class FakeAutoUpdater extends EventEmitter {
checkCalls = 0
quitInstallCalls = 0
feedUrls: unknown[] = []
checkResults: unknown[] = []
setFeedURL(url: unknown): void {
this.feedUrls.push(url)
}
async checkForUpdates(): Promise<void> {
async checkForUpdates(): Promise<unknown> {
this.checkCalls++
const result = this.checkResults.length
? this.checkResults.shift()
: { isUpdateAvailable: false, updateInfo: { version: '0.36.0' } }
if (result instanceof Error) {
this.emit('error', result)
throw result
}
return result
}
async downloadUpdate(): Promise<void> {
@@ -60,12 +69,18 @@ class FakeUpdateWindow extends EventEmitter {
async function loadUpdaterModule() {
const autoUpdater = new FakeAutoUpdater()
const dialogCalls: DialogCall[] = []
const dialogResponses: number[] = []
const externalUrls: string[] = []
const logMessages: string[] = []
let quitCalls = 0
await mock.module('electron', {
namedExports: {
app: {
isPackaged: true,
quit() {
quitCalls++
},
},
dialog: {
async showMessageBox(parentOrOptions: unknown, maybeOptions?: DialogCall) {
@@ -74,7 +89,15 @@ async function loadUpdaterModule() {
...options,
parent: maybeOptions ? parentOrOptions : undefined,
})
return { response: 1 }
return { response: dialogResponses.shift() ?? 1 }
},
showErrorBox() {
logMessages.push('showErrorBox')
},
},
shell: {
async openExternal(url: string) {
externalUrls.push(url)
},
},
},
@@ -91,6 +114,9 @@ async function loadUpdaterModule() {
info(message: string) {
logMessages.push(message)
},
warn(message: string) {
logMessages.push(message)
},
error(message: string) {
logMessages.push(message)
},
@@ -117,50 +143,100 @@ async function loadUpdaterModule() {
return {
autoUpdater,
dialogCalls,
dialogResponses,
externalUrls,
getQuitCalls: () => quitCalls,
checkUpdate: mod.checkUpdate as unknown as (win: FakeUpdateWindow) => void,
manualCheckForUpdates: mod.manualCheckForUpdates as () => void,
recoverRequiredDesktopUpdate: mod.recoverRequiredDesktopUpdate as (requirement: {
currentVersion: string
minRuntimeVersion: string
userDataDir: string
}) => Promise<void>,
}
}
test('automatic stable updates defer the install prompt until the app window is active', async () => {
const { autoUpdater, dialogCalls, checkUpdate, manualCheckForUpdates } = await loadUpdaterModule()
const sent: unknown[][] = []
const win = new FakeUpdateWindow({
send: (...args: unknown[]) => sent.push(args),
test('desktop updater workflows', async (t) => {
const harness = await loadUpdaterModule()
await t.test('required update retries with GitHub, downloads, and installs without opening the app', async () => {
const networkError = Object.assign(new Error('network timeout'), { code: 'ETIMEDOUT' })
harness.autoUpdater.checkResults.push(networkError, {
isUpdateAvailable: true,
updateInfo: { version: '0.36.0' },
cancellationToken: {},
})
harness.dialogResponses.push(0)
await harness.recoverRequiredDesktopUpdate({
currentVersion: '0.35.0',
minRuntimeVersion: '0.35.1',
userDataDir: '/tmp/chatlab-data',
})
assert.equal(harness.autoUpdater.checkCalls, 2)
assert.equal(harness.autoUpdater.downloadCalls, 1)
assert.equal(harness.autoUpdater.quitInstallCalls, 1)
assert.equal(harness.getQuitCalls(), 0)
assert.deepEqual(harness.autoUpdater.feedUrls.at(-1), {
provider: 'github',
owner: 'ChatLab',
repo: 'ChatLab',
})
})
win.focused = false
checkUpdate(win)
await t.test('required update failure offers the official download page and quits safely', async () => {
harness.autoUpdater.checkResults.push(null)
harness.dialogResponses.push(0, 0)
autoUpdater.emit('update-available', { version: '0.28.2' })
await Promise.resolve()
await harness.recoverRequiredDesktopUpdate({
currentVersion: '0.35.0',
minRuntimeVersion: '0.35.1',
userDataDir: '/tmp/chatlab-data',
})
assert.equal(autoUpdater.downloadCalls, 1)
assert.equal(dialogCalls.length, 0)
assert.equal(autoUpdater.autoDownload, false)
assert.equal(autoUpdater.autoInstallOnAppQuit, false)
assert.equal(harness.dialogCalls.at(-1)?.title, 'update.requiredUpdateFailedTitle')
assert.deepEqual(harness.externalUrls, ['https://github.com/ChatLab/ChatLab/releases'])
assert.equal(harness.getQuitCalls(), 1)
})
autoUpdater.emit('update-downloaded', { version: '0.28.2' })
await Promise.resolve()
await t.test('automatic stable updates defer the install prompt until the app window is active', async () => {
const sent: unknown[][] = []
const downloadsBefore = harness.autoUpdater.downloadCalls
const win = new FakeUpdateWindow({
send: (...args: unknown[]) => sent.push(args),
})
win.focused = false
assert.equal(dialogCalls.length, 0)
harness.checkUpdate(win)
win.focused = true
win.emit('focus')
await new Promise<void>((resolve) => setImmediate(resolve))
harness.autoUpdater.emit('update-available', { version: '0.36.1' })
await Promise.resolve()
assert.equal(dialogCalls.length, 1)
assert.equal(dialogCalls[0]?.title, 'update.downloadComplete')
assert.equal(dialogCalls[0]?.message, 'update.readyToInstall')
assert.equal(dialogCalls[0]?.parent, win)
assert.equal(harness.autoUpdater.downloadCalls, downloadsBefore + 1)
assert.equal(harness.autoUpdater.autoDownload, false)
assert.equal(harness.autoUpdater.autoInstallOnAppQuit, false)
manualCheckForUpdates()
autoUpdater.emit('update-available', { version: '0.28.3' })
await new Promise<void>((resolve) => setImmediate(resolve))
harness.autoUpdater.emit('update-downloaded', { version: '0.36.1' })
await Promise.resolve()
assert.equal(dialogCalls.length, 2)
assert.equal(dialogCalls[1]?.title, 'update.newVersionTitle:0.28.3')
assert.equal(dialogCalls[1]?.message, 'update.newVersionMessage:0.28.3')
assert.equal(dialogCalls[1]?.parent, win)
assert.equal(autoUpdater.downloadCalls, 1)
assert.notEqual(harness.dialogCalls.at(-1)?.title, 'update.downloadComplete')
win.focused = true
win.emit('focus')
await new Promise<void>((resolve) => setImmediate(resolve))
assert.equal(harness.dialogCalls.at(-1)?.title, 'update.downloadComplete')
assert.equal(harness.dialogCalls.at(-1)?.message, 'update.readyToInstall')
assert.equal(harness.dialogCalls.at(-1)?.parent, win)
harness.manualCheckForUpdates()
harness.autoUpdater.emit('update-available', { version: '0.36.2' })
await new Promise<void>((resolve) => setImmediate(resolve))
assert.equal(harness.dialogCalls.at(-1)?.title, 'update.newVersionTitle:0.36.2')
assert.equal(harness.dialogCalls.at(-1)?.message, 'update.newVersionMessage:0.36.2')
assert.equal(harness.dialogCalls.at(-1)?.parent, win)
assert.equal(harness.autoUpdater.downloadCalls, downloadsBefore + 1)
})
})
+119 -5
View File
@@ -1,14 +1,23 @@
import { dialog, app, type BrowserWindow } from 'electron'
import { dialog, app, shell, type BrowserWindow } from 'electron'
import { autoUpdater } from 'electron-updater'
import { platform } from '@electron-toolkit/utils'
import { isRuntimeVersionAtLeast } from '@openchatlab/node-runtime/data-dir-compat'
import { logger } from '../logger'
import { closeWorkerAsync } from '../worker/workerManager'
import { t } from '../i18n'
import { configureUpdateProxy, handleUpdateError, resetToDefaultSource } from './source'
import type { DesktopUpdateRequirement } from '../runtime/compat'
import {
configureUpdateProxy,
handleUpdateError,
isUpdateNetworkError,
resetToDefaultSource,
switchToGitHubUpdateSource,
} from './source'
type AppWithQuitFlag = typeof app & { isQuiting?: boolean }
const appWithQuitFlag = app as AppWithQuitFlag
const OFFICIAL_RELEASES_URL = 'https://github.com/ChatLab/ChatLab/releases'
let isFirstShow = true
let isManualCheck = false
@@ -31,11 +40,67 @@ function runWhenWindowActive(win: BrowserWindow, callback: () => void): void {
}
}
export async function recoverRequiredDesktopUpdate(requirement: DesktopUpdateRequirement): Promise<void> {
configureUpdater()
configureUpdateProxy()
resetToDefaultSource()
const prompt = await dialog.showMessageBox({
title: t('update.requiredTitle'),
message: t('update.requiredMessage', {
currentVersion: requirement.currentVersion,
minRuntimeVersion: requirement.minRuntimeVersion,
}),
detail: t('update.requiredDetail', {
minRuntimeVersion: requirement.minRuntimeVersion,
userDataDir: requirement.userDataDir,
}),
buttons: [t('update.updateNow'), t('update.openDownloadPage'), t('update.quit')],
defaultId: 0,
cancelId: 2,
type: 'warning',
noLink: true,
})
if (prompt.response === 1) {
await openOfficialReleasesAndQuit()
return
}
if (prompt.response !== 0) {
app.quit()
return
}
const logUpdaterError = (error: Error): void => {
logger.error('[Update] Required desktop update error', error)
}
autoUpdater.on('error', logUpdaterError)
try {
const update = await checkRequiredUpdateWithFallback()
if (
!update?.isUpdateAvailable ||
!isRuntimeVersionAtLeast(update.updateInfo.version, requirement.minRuntimeVersion)
) {
throw new Error(
`No compatible desktop update is available (required ${requirement.minRuntimeVersion}, received ${update?.updateInfo.version ?? 'none'}).`
)
}
await autoUpdater.downloadUpdate(update.cancellationToken)
appWithQuitFlag.isQuiting = true
autoUpdater.quitAndInstall(true, true)
} catch (error) {
logger.error('[Update] Failed to recover from an incompatible data directory', error)
await showRequiredUpdateFailure(requirement.minRuntimeVersion)
} finally {
autoUpdater.off('error', logUpdaterError)
}
}
export function checkUpdate(win: BrowserWindow): void {
configureUpdateProxy()
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
configureUpdater()
let showUpdateMessageBox = false
let showInstallMessageBox = false
@@ -196,3 +261,52 @@ export function simulateUpdateDialog(win: BrowserWindow): void {
function isPreReleaseVersion(version: string): boolean {
return /-/.test(version)
}
function configureUpdater(): void {
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
}
async function checkRequiredUpdateWithFallback() {
try {
return await autoUpdater.checkForUpdates()
} catch (error) {
if (!(error instanceof Error) || !isUpdateNetworkError(error)) throw error
logger.warn('[Update] Required update check failed on R2 mirror; retrying with GitHub')
switchToGitHubUpdateSource()
return await autoUpdater.checkForUpdates()
}
}
async function showRequiredUpdateFailure(minRuntimeVersion: string): Promise<void> {
const result = await dialog.showMessageBox({
title: t('update.requiredUpdateFailedTitle'),
message: t('update.requiredUpdateFailedMessage'),
detail: t('update.requiredUpdateFailedDetail', { minRuntimeVersion }),
buttons: [t('update.openDownloadPage'), t('update.quit')],
defaultId: 0,
cancelId: 1,
type: 'error',
noLink: true,
})
if (result.response === 0) {
await openOfficialReleasesAndQuit()
return
}
app.quit()
}
async function openOfficialReleasesAndQuit(): Promise<void> {
try {
await shell.openExternal(OFFICIAL_RELEASES_URL)
} catch (error) {
logger.error('[Update] Failed to open the official releases page', error)
dialog.showErrorBox(
t('update.requiredUpdateFailedTitle'),
`${t('update.openDownloadFailed')}\n\n${OFFICIAL_RELEASES_URL}`
)
} finally {
app.quit()
}
}
+4 -4
View File
@@ -30,11 +30,11 @@ export function resetToDefaultSource(): void {
export function handleUpdateError(error: Error): void {
logger.error(`[Update] Update error (${currentSource}): ${error.message || error}`)
if (currentSource !== 'r2' || hasTriedFallback || !isNetworkError(error)) return
if (currentSource !== 'r2' || hasTriedFallback || !isUpdateNetworkError(error)) return
hasTriedFallback = true
logger.info('[Update] R2 mirror failed, trying GitHub fallback...')
switchToGitHub()
switchToGitHubUpdateSource()
setTimeout(() => {
autoUpdater.checkForUpdates().catch((retryError) => {
@@ -51,7 +51,7 @@ function switchToR2Mirror(): void {
})
}
function switchToGitHub(): void {
export function switchToGitHubUpdateSource(): void {
currentSource = 'github'
autoUpdater.setFeedURL({
provider: 'github',
@@ -61,7 +61,7 @@ function switchToGitHub(): void {
logger.info('[Update] Switched to GitHub fallback source')
}
function isNetworkError(error: Error): boolean {
export function isUpdateNetworkError(error: Error): boolean {
const networkErrorKeywords = [
'ECONNREFUSED',
'ENOTFOUND',