diff --git a/frontend/src/bootstrap/realtimeResidents.ts b/frontend/src/bootstrap/realtimeResidents.ts
index 0efa1d2859..182b52eff8 100644
--- a/frontend/src/bootstrap/realtimeResidents.ts
+++ b/frontend/src/bootstrap/realtimeResidents.ts
@@ -21,6 +21,10 @@ import {
bootstrapVirtualDisplayPrompt,
disposeVirtualDisplayPrompt,
} from '@/services/virtualDisplayPrompt'
+import {
+ bootstrapBetterGIInstallRecovery,
+ disposeBetterGIInstallRecovery,
+} from '@/services/bettergiInstallRecovery'
let registered = false
@@ -71,6 +75,10 @@ export function bootstrapRealtimeResidents(): void {
bootstrap: bootstrapVirtualDisplayPrompt,
dispose: disposeVirtualDisplayPrompt,
})
+ registerResidentResource('bettergi-install-recovery', {
+ bootstrap: bootstrapBetterGIInstallRecovery,
+ dispose: disposeBetterGIInstallRecovery,
+ })
registered = true
}
bootstrapResidentResources()
diff --git a/frontend/src/components/BetterGIInstallRecoveryModal.vue b/frontend/src/components/BetterGIInstallRecoveryModal.vue
new file mode 100644
index 0000000000..86adb0da2c
--- /dev/null
+++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+ {{ t('bootstrap.comp.bettergiRecoveryMessage') }}
+
+
+ {{ prompt.installedVersion || t('bootstrap.comp.bettergiRecoveryUnknown') }}
+
+
+ {{ prompt.targetVersion || t('bootstrap.comp.bettergiRecoveryUnknown') }}
+
+
+ {{ prompt.channel === 'stable' ? t('bootstrap.comp.channelStable') : prompt.channel }}
+
+
+ {{ t(prompt.takeoverEnabled ? 'bootstrap.comp.bettergiRecoveryEnabled' : 'bootstrap.comp.bettergiRecoveryDisabled') }}
+
+
+
+
+ {{ t('bootstrap.comp.bettergiRecoveryAcknowledgement') }}
+
+
+
+
+ {{ t('bootstrap.comp.bettergiRecoveryStop') }}
+
+
+ {{ t('bootstrap.comp.bettergiRecoveryRetry') }}
+
+
+ {{ t('bootstrap.comp.bettergiRecoverySkip') }}
+
+
+
+
+
+
+
diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json
index f5abbfe3aa..47c5200816 100644
--- a/frontend/src/i18n/bootstrap/en-US.json
+++ b/frontend/src/i18n/bootstrap/en-US.json
@@ -3,6 +3,21 @@
"languageSaveFailed": "Failed to save the language setting; reverted to the previous one."
},
"comp": {
+ "bettergiRecoveryTitle": "Previous BetterGI installation is unconfirmed",
+ "bettergiRecoveryMessage": "MAS found an unresolved BetterGI installation record. The release resources may not have finished publishing, or installation may have been interrupted. Choose how this task should proceed.",
+ "bettergiRecoveryCurrent": "Installed version",
+ "bettergiRecoveryTarget": "Previous target",
+ "bettergiRecoveryChannel": "Update channel",
+ "bettergiRecoveryTakeover": "Managed updates",
+ "bettergiRecoveryEnabled": "Enabled",
+ "bettergiRecoveryDisabled": "Disabled",
+ "bettergiRecoveryRisk": "If the previous installer partially modified the installation, skipping could run incomplete files. MAS checks the executable version and architecture and ensures installers have stopped, but cannot verify every file.",
+ "bettergiRecoveryAcknowledgement": "I have repaired the installation with the official installer or confirmed BetterGI works, and understand the risk of skipping.",
+ "bettergiRecoveryRetry": "Retry installation",
+ "bettergiRecoverySkip": "Skip installation and continue",
+ "bettergiRecoveryStop": "Cancel this task",
+ "bettergiRecoveryUnknown": "Unknown",
+ "bettergiRecoverySendFailed": "Could not send your choice. Check the backend connection and try again.",
"backendAboutUpdateWhich": "The backend is about to update, which restarts it and interrupts whatever is running. Continue?",
"backendStarting": "Starting the backend",
"backendUpdateAvailableClick": "A backend update is available — click to update",
diff --git a/frontend/src/i18n/bootstrap/ja-JP.json b/frontend/src/i18n/bootstrap/ja-JP.json
index 2b4ed31807..7944d85fed 100644
--- a/frontend/src/i18n/bootstrap/ja-JP.json
+++ b/frontend/src/i18n/bootstrap/ja-JP.json
@@ -3,6 +3,21 @@
"languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。"
},
"comp": {
+ "bettergiRecoveryTitle": "BetterGI の前回インストールが未確認です",
+ "bettergiRecoveryMessage": "前回の BetterGI 更新が完了したことを確認できません。配布ファイルの公開が遅れているか、インストールが中断された可能性があります。今回の処理を選択してください。",
+ "bettergiRecoveryCurrent": "現在のバージョン",
+ "bettergiRecoveryTarget": "前回の更新先",
+ "bettergiRecoveryChannel": "更新チャネル",
+ "bettergiRecoveryTakeover": "自動更新",
+ "bettergiRecoveryEnabled": "有効",
+ "bettergiRecoveryDisabled": "無効",
+ "bettergiRecoveryRisk": "前回のインストールでファイルが一部変更された場合、スキップすると不完全な環境で起動する可能性があります。MAS は実行ファイルのバージョンと構成、インストーラーの終了を確認しますが、すべてのファイルの完全性は保証できません。",
+ "bettergiRecoveryAcknowledgement": "公式インストーラーで修復したか、BetterGI が正常に動作することを確認し、スキップのリスクを理解しました。",
+ "bettergiRecoveryRetry": "インストールを再試行",
+ "bettergiRecoverySkip": "スキップして続行",
+ "bettergiRecoveryStop": "今回のタスクを中止",
+ "bettergiRecoveryUnknown": "不明",
+ "bettergiRecoverySendFailed": "選択を送信できません。バックエンドの接続を確認して再試行してください。",
"backendAboutUpdateWhich": "バックエンドを更新します。更新にはバックエンドの再起動が必要で、実行中のタスクは中断されます。続行しますか?",
"backendStarting": "バックエンドを起動しています",
"backendUpdateAvailableClick": "バックエンドの更新があります。クリックして更新してください",
diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json
index eb4f5910e6..86544e6097 100644
--- a/frontend/src/i18n/bootstrap/zh-CN.json
+++ b/frontend/src/i18n/bootstrap/zh-CN.json
@@ -3,6 +3,21 @@
"languageSaveFailed": "语言设置保存失败,已恢复原语言"
},
"comp": {
+ "bettergiRecoveryTitle": "BetterGI 上次安装未完成",
+ "bettergiRecoveryMessage": "检测到上一次 BetterGI 更新的未确认记录。可能是新版本安装资源尚未分发完成,或安装中途取消。请选择如何处理本次任务。",
+ "bettergiRecoveryCurrent": "当前程序版本",
+ "bettergiRecoveryTarget": "上次安装目标",
+ "bettergiRecoveryChannel": "更新通道",
+ "bettergiRecoveryTakeover": "自动更新",
+ "bettergiRecoveryEnabled": "已开启",
+ "bettergiRecoveryDisabled": "已关闭",
+ "bettergiRecoveryRisk": "若上次安装已修改程序文件,跳过可能会启动不完整的安装。MAS 会检查程序版本与架构,并确认安装器已退出,但无法保证所有文件完整。",
+ "bettergiRecoveryAcknowledgement": "我已使用官方安装器修复或确认 BetterGI 能正常运行,并理解跳过安装的风险。",
+ "bettergiRecoveryRetry": "重试安装",
+ "bettergiRecoverySkip": "跳过安装并继续",
+ "bettergiRecoveryStop": "取消本次任务",
+ "bettergiRecoveryUnknown": "未知",
+ "bettergiRecoverySendFailed": "操作未能发送,请检查后端连接后重试。",
"backendAboutUpdateWhich": "即将更新后端,这需要重启后端程序,你当前正在运行的任务将会被中断。确认继续?",
"backendStarting": "正在启动后端",
"backendUpdateAvailableClick": "检测到后端更新,点击以更新后端",
diff --git a/frontend/src/services/bettergiInstallRecovery.ts b/frontend/src/services/bettergiInstallRecovery.ts
new file mode 100644
index 0000000000..d942a99ffa
--- /dev/null
+++ b/frontend/src/services/bettergiInstallRecovery.ts
@@ -0,0 +1,73 @@
+// 应用级 BetterGI 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。
+// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。
+
+import { ref } from 'vue'
+import { onDisconnected, send } from '@/services/websocket/connection'
+import { subscribe, unsubscribe } from '@/services/websocket/subscriptions'
+import {
+ WS_BETTERGI_INSTALL_DECISION,
+ WS_BETTERGI_INSTALL_PROMPT,
+ WS_BETTERGI_INSTALL_PROMPT_CLOSED,
+ WS_ID_TASK_MANAGER,
+ type WSBetterGIInstallPromptData,
+} from '@/services/websocket/types'
+
+const logger = window.electronAPI.getLogger('BetterGI安装恢复')
+export const bettergiInstallPrompts = ref
([])
+export const bettergiInstallDecisionPending = ref(null)
+
+let subscriptionIds: string[] = []
+let disposeDisconnect: (() => void) | null = null
+
+const forgetRequest = (requestId: string) => {
+ bettergiInstallPrompts.value = bettergiInstallPrompts.value.filter(
+ prompt => prompt.requestId !== requestId
+ )
+ if (bettergiInstallDecisionPending.value === requestId) {
+ bettergiInstallDecisionPending.value = null
+ }
+}
+
+export function bootstrapBetterGIInstallRecovery(): void {
+ if (subscriptionIds.length) return
+ subscriptionIds = [
+ subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT }, message => {
+ const prompt = message.data
+ if (!prompt.requestId || !prompt.taskId || bettergiInstallPrompts.value.some(
+ previous => previous.requestId === prompt.requestId
+ )) return
+ bettergiInstallPrompts.value.push(prompt)
+ }),
+ subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT_CLOSED }, message => {
+ forgetRequest(message.data.requestId)
+ }),
+ ]
+ disposeDisconnect = onDisconnected(() => {
+ bettergiInstallPrompts.value = []
+ bettergiInstallDecisionPending.value = null
+ })
+}
+
+export function respondToBetterGIInstallRecovery(action: 'retry' | 'skip' | 'stop'): boolean {
+ const prompt = bettergiInstallPrompts.value[0]
+ if (!prompt || bettergiInstallDecisionPending.value) return false
+ const sent = send(WS_ID_TASK_MANAGER, WS_BETTERGI_INSTALL_DECISION, {
+ requestId: prompt.requestId,
+ taskId: prompt.taskId,
+ action,
+ })
+ if (!sent) {
+ logger.warn('未能发送 BetterGI 安装恢复选择')
+ return false
+ }
+ bettergiInstallDecisionPending.value = prompt.requestId
+ return true
+}
+
+export function disposeBetterGIInstallRecovery(): void {
+ for (const id of subscriptionIds.splice(0)) unsubscribe(id)
+ disposeDisconnect?.()
+ disposeDisconnect = null
+ bettergiInstallPrompts.value = []
+ bettergiInstallDecisionPending.value = null
+}
diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts
index d7f0005eba..d7b3c9121d 100644
--- a/frontend/src/services/websocket/types.ts
+++ b/frontend/src/services/websocket/types.ts
@@ -50,6 +50,11 @@ export const WS_TASK_CREATED = 'task.created'
export const WS_TASK_SCRIPT_STARTED = 'task.script.started'
export const WS_TASK_SCRIPT_FINISHED = 'task.script.finished'
+// BetterGI 安装恢复:由后端单次发起,用户决定后以 requestId 回应。
+export const WS_BETTERGI_INSTALL_PROMPT = 'bettergi.install.prompt'
+export const WS_BETTERGI_INSTALL_PROMPT_CLOSED = 'bettergi.install.prompt.closed'
+export const WS_BETTERGI_INSTALL_DECISION = 'bettergi.install.decision'
+
// 应用生命周期与电源(id=Main)
export const WS_BACKEND_SHUTDOWN_READY = 'backend.shutdown.ready'
export const WS_FRONTEND_CLOSE_REQUESTED = 'frontend.close.requested'
@@ -160,6 +165,20 @@ export interface WSTaskScriptFinishedData extends WSTaskScriptStartedData {
partialFailure?: 'yes' | 'no' | 'unknown'
}
+/** BetterGI 上次安装未确认时的请求(id=TaskManager) */
+export interface WSBetterGIInstallPromptData {
+ requestId: string
+ taskId: string
+ channel: string
+ takeoverEnabled: boolean
+ installedVersion: string
+ targetVersion: string
+}
+
+export interface WSBetterGIInstallPromptClosedData {
+ requestId: string
+}
+
/** 新任务创建通知数据 (id=TaskManager, type=task.created) */
export interface WSTaskCreatedData {
trigger?: TaskTriggerReason
@@ -329,6 +348,8 @@ interface WSMessageDataMap {
[WS_TASK_CREATED]: WSTaskCreatedData
[WS_TASK_SCRIPT_STARTED]: WSTaskScriptStartedData
[WS_TASK_SCRIPT_FINISHED]: WSTaskScriptFinishedData
+ [WS_BETTERGI_INSTALL_PROMPT]: WSBetterGIInstallPromptData
+ [WS_BETTERGI_INSTALL_PROMPT_CLOSED]: WSBetterGIInstallPromptClosedData
[WS_BACKEND_SHUTDOWN_READY]: WSEmptyData
[WS_FRONTEND_CLOSE_REQUESTED]: FrontendCloseRequest
[WS_FRONTEND_RESTART_REQUESTED]: FrontendCloseRequest
From ed2814d02c69a10e3dc40ee915c5fb259c10239f Mon Sep 17 00:00:00 2001
From: youngslin <121206610+youngslin@users.noreply.github.com>
Date: Tue, 10 Nov 2026 07:50:45 +0100
Subject: [PATCH 04/19] fix(bettergi): translate stable and beta recovery
channel labels
---
frontend/src/components/BetterGIInstallRecoveryModal.vue | 2 +-
frontend/src/i18n/bootstrap/en-US.json | 2 ++
frontend/src/i18n/bootstrap/ja-JP.json | 2 ++
frontend/src/i18n/bootstrap/zh-CN.json | 2 ++
4 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/BetterGIInstallRecoveryModal.vue b/frontend/src/components/BetterGIInstallRecoveryModal.vue
index 86adb0da2c..92643a88b5 100644
--- a/frontend/src/components/BetterGIInstallRecoveryModal.vue
+++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue
@@ -46,7 +46,7 @@ function decide(action: 'retry' | 'skip' | 'stop') {
{{ prompt.targetVersion || t('bootstrap.comp.bettergiRecoveryUnknown') }}
- {{ prompt.channel === 'stable' ? t('bootstrap.comp.channelStable') : prompt.channel }}
+ {{ prompt.channel === 'stable' ? t('bootstrap.comp.bettergiRecoveryStable') : t('bootstrap.comp.bettergiRecoveryBeta') }}
{{ t(prompt.takeoverEnabled ? 'bootstrap.comp.bettergiRecoveryEnabled' : 'bootstrap.comp.bettergiRecoveryDisabled') }}
diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json
index 47c5200816..abdf6d9aa6 100644
--- a/frontend/src/i18n/bootstrap/en-US.json
+++ b/frontend/src/i18n/bootstrap/en-US.json
@@ -3,6 +3,8 @@
"languageSaveFailed": "Failed to save the language setting; reverted to the previous one."
},
"comp": {
+ "bettergiRecoveryStable": "Stable",
+ "bettergiRecoveryBeta": "Beta",
"bettergiRecoveryTitle": "Previous BetterGI installation is unconfirmed",
"bettergiRecoveryMessage": "MAS found an unresolved BetterGI installation record. The release resources may not have finished publishing, or installation may have been interrupted. Choose how this task should proceed.",
"bettergiRecoveryCurrent": "Installed version",
diff --git a/frontend/src/i18n/bootstrap/ja-JP.json b/frontend/src/i18n/bootstrap/ja-JP.json
index 7944d85fed..5247def71a 100644
--- a/frontend/src/i18n/bootstrap/ja-JP.json
+++ b/frontend/src/i18n/bootstrap/ja-JP.json
@@ -3,6 +3,8 @@
"languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。"
},
"comp": {
+ "bettergiRecoveryStable": "正式版",
+ "bettergiRecoveryBeta": "ベータ版",
"bettergiRecoveryTitle": "BetterGI の前回インストールが未確認です",
"bettergiRecoveryMessage": "前回の BetterGI 更新が完了したことを確認できません。配布ファイルの公開が遅れているか、インストールが中断された可能性があります。今回の処理を選択してください。",
"bettergiRecoveryCurrent": "現在のバージョン",
diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json
index 86544e6097..3e25410fad 100644
--- a/frontend/src/i18n/bootstrap/zh-CN.json
+++ b/frontend/src/i18n/bootstrap/zh-CN.json
@@ -3,6 +3,8 @@
"languageSaveFailed": "语言设置保存失败,已恢复原语言"
},
"comp": {
+ "bettergiRecoveryStable": "正式版",
+ "bettergiRecoveryBeta": "公测版",
"bettergiRecoveryTitle": "BetterGI 上次安装未完成",
"bettergiRecoveryMessage": "检测到上一次 BetterGI 更新的未确认记录。可能是新版本安装资源尚未分发完成,或安装中途取消。请选择如何处理本次任务。",
"bettergiRecoveryCurrent": "当前程序版本",
From 6cf5a9410657ab2ee827ded88ab211410e565882 Mon Sep 17 00:00:00 2001
From: youngslin <121206610+youngslin@users.noreply.github.com>
Date: Tue, 10 Nov 2026 07:51:53 +0100
Subject: [PATCH 05/19] test(bettergi): cover skip, retry, mark races,
installer and prompt fallback
---
tests/task/test_bettergi_program_update.py | 238 ++++++++++++++++++++-
1 file changed, 236 insertions(+), 2 deletions(-)
diff --git a/tests/task/test_bettergi_program_update.py b/tests/task/test_bettergi_program_update.py
index cb98b9566e..f5df20a9f5 100644
--- a/tests/task/test_bettergi_program_update.py
+++ b/tests/task/test_bettergi_program_update.py
@@ -223,7 +223,7 @@ async def test_unsafe_and_cancelled_preparation_never_runs_users(
@pytest.mark.asyncio
async def test_disabled_takeover_does_not_query_and_blocks_unfinished_install(
- installation,
+ installation, monkeypatch
):
root, config, _ = installation
config.Update.TakeoverEnabled = False
@@ -237,10 +237,18 @@ async def test_disabled_takeover_does_not_query_and_blocks_unfinished_install(
assert json.loads((root / "User/config.json").read_text()) == {"custom": True}
marker = script_install.workspace(root) / "bgi-install.json"
marker.parent.mkdir(parents=True, exist_ok=True)
- marker.write_text("{}")
+ marker.write_text('{"target": "0.56.0"}')
instance._program_update_prepared = False
+ instance.task_info = SimpleNamespace(task_id=str(uuid.uuid4()))
+ monkeypatch.setattr(
+ manager.install_recovery,
+ "ask_install_recovery",
+ AsyncMock(side_effect=script_install.InstallUnsafeError("用户界面未连接")),
+ )
with pytest.raises(script_install.InstallUnsafeError):
await instance._prepare_program_update()
+ assert marker.exists()
+ update.request_release.assert_not_awaited()
@pytest.mark.asyncio
@@ -527,3 +535,229 @@ async def download(url, destination, **kwargs):
update.download_script_package.side_effect = download
assert "已更新" in await update.update_before_run(root, config)
+
+
+def _recovery_instance(root, config):
+ instance = object.__new__(manager.BetterGIManager)
+ instance._update_root = root
+ instance._program_update_prepared = False
+ instance.script_config = config
+ instance.script_info = SimpleNamespace(script_id=str(uuid.uuid4()), log="")
+ instance.task_info = SimpleNamespace(task_id=str(uuid.uuid4()))
+ return instance
+
+
+@pytest.mark.asyncio
+async def test_skip_after_manual_repair_with_takeover_off(installation, monkeypatch):
+ root, config, _ = installation
+ config.Update.TakeoverEnabled = False
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}', encoding="utf-8")
+ monkeypatch.setattr(
+ manager.install_recovery,
+ "ask_install_recovery",
+ AsyncMock(return_value=("skip", marker.read_bytes())),
+ )
+ monkeypatch.setattr(manager.Publisher, "send", AsyncMock(return_value=True))
+ instance = _recovery_instance(root, config)
+ await instance._prepare_program_update()
+ await instance._prepare_program_update()
+ assert instance._program_update_prepared
+ assert instance.check_result == "Pass"
+ assert not marker.exists()
+ await update.confirm_current_install(root)
+ update.request_release.assert_not_awaited()
+ update.run_installer.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_skip_with_takeover_on_does_not_install_again(installation, monkeypatch):
+ root, config, _ = installation
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}', encoding="utf-8")
+ monkeypatch.setattr(
+ manager.install_recovery,
+ "ask_install_recovery",
+ AsyncMock(return_value=("skip", marker.read_bytes())),
+ )
+ monkeypatch.setattr(manager.Publisher, "send", AsyncMock(return_value=True))
+ instance = _recovery_instance(root, config)
+ await instance._prepare_program_update()
+ assert not marker.exists()
+ update.request_release.assert_not_awaited()
+ assert json.loads((root / "User/config.json").read_text())[update.NOTICE_KEY] == update.IGNORED_VERSION
+ update.recover_update_notice(root)
+
+
+@pytest.mark.asyncio
+async def test_retry_explicitly_allowed_with_takeover_off(installation, monkeypatch):
+ root, config, _ = installation
+ config.Update.TakeoverEnabled = False
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}', encoding="utf-8")
+ monkeypatch.setattr(
+ manager.install_recovery,
+ "ask_install_recovery",
+ AsyncMock(return_value=("retry", marker.read_bytes())),
+ )
+ instance = _recovery_instance(root, config)
+ await instance._prepare_program_update()
+ await instance._prepare_program_update()
+ assert not marker.exists()
+ assert update.request_release.await_count == 1
+ assert update.run_installer.await_count == 1
+ assert instance.check_result == "Pass"
+
+
+@pytest.mark.asyncio
+async def test_retry_failure_preserves_marker_and_aborts(installation, monkeypatch):
+ root, config, _ = installation
+ config.Update.TakeoverEnabled = False
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}', encoding="utf-8")
+ monkeypatch.setattr(
+ manager.install_recovery,
+ "ask_install_recovery",
+ AsyncMock(return_value=("retry", marker.read_bytes())),
+ )
+ update.run_installer.side_effect = RuntimeError("official installer failed")
+ instance = _recovery_instance(root, config)
+ with pytest.raises(RuntimeError, match="official installer failed"):
+ await instance._prepare_program_update()
+ assert marker.exists()
+ assert not instance._program_update_prepared
+
+
+@pytest.mark.asyncio
+async def test_skip_changed_marker_is_rejected(installation):
+ from app.task.BetterGI.tools import install_recovery
+
+ root, _, _ = installation
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}')
+ snapshot = marker.read_bytes()
+ marker.write_text('{"target": "0.57.0"}')
+ with pytest.raises(script_install.InstallUnsafeError, match="变化"):
+ await install_recovery.skip_unconfirmed_install(root, snapshot)
+ assert marker.read_text() == '{"target": "0.57.0"}'
+
+
+@pytest.mark.asyncio
+async def test_skip_invalid_marker_or_unrecognized_install_stays_blocked(
+ installation, monkeypatch
+):
+ from app.task.BetterGI.tools import install_recovery
+
+ root, _, _ = installation
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text("not valid json")
+ with pytest.raises(script_install.InstallUnsafeError, match="内容异常"):
+ await install_recovery.skip_unconfirmed_install(root, marker.read_bytes())
+ assert marker.exists()
+
+ marker.write_text('{"target": "0.56.0"}')
+ monkeypatch.setattr(script_install, "read_pe_architecture", lambda path: "x86")
+ with pytest.raises(script_install.InstallUnsafeError, match="版本或架构"):
+ await install_recovery.skip_unconfirmed_install(root, marker.read_bytes())
+ assert marker.exists()
+
+
+@pytest.mark.asyncio
+async def test_installer_outside_workspace_blocks_recovery(tmp_path, monkeypatch):
+ from app.task.BetterGI.tools import program_update
+
+ process = SimpleNamespace(
+ info={
+ "exe": str(tmp_path / "Downloads" / "BetterGI.Install.0.67.0.exe"),
+ "name": "BetterGI.Install.0.67.0.exe",
+ }
+ )
+ monkeypatch.setattr(program_update.psutil, "process_iter", lambda _: [process])
+ with pytest.raises(script_install.InstallUnsafeError, match="安装器仍在运行"):
+ program_update.assert_idle(tmp_path / "BetterGI")
+
+
+@pytest.mark.asyncio
+async def test_recovery_prompt_without_frontend_fails_closed(installation, monkeypatch):
+ from app.task.BetterGI.tools import install_recovery
+
+ root, _, _ = installation
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}')
+ send = AsyncMock(return_value=False)
+ monkeypatch.setattr(install_recovery.Publisher, "send", send)
+ with pytest.raises(script_install.InstallUnsafeError, match="未连接主界面"):
+ await install_recovery.ask_install_recovery(
+ "task1", root, channel="stable", takeover_enabled=False
+ )
+ assert marker.exists()
+ assert send.await_count == 2 # 提示发不出去仍发送关闭;没有默认跳过。
+
+
+@pytest.mark.asyncio
+async def test_recovery_prompt_accepts_only_matching_request(installation, monkeypatch):
+ from app.core import Dispatcher
+ from app.core.ws import protocol
+ from app.models.schema import WSEnvelope
+ from app.task.BetterGI.tools import install_recovery
+
+ root, _, _ = installation
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}')
+ outgoing = []
+
+ async def send(*, id, type, data):
+ outgoing.append(type)
+ if type == protocol.BETTERGI_INSTALL_PROMPT:
+ Dispatcher.dispatch(WSEnvelope(
+ id=id,
+ type=protocol.BETTERGI_INSTALL_DECISION,
+ data={"taskId": data["taskId"], "requestId": "wrong", "action": "skip"},
+ ))
+ Dispatcher.dispatch(WSEnvelope(
+ id=id,
+ type=protocol.BETTERGI_INSTALL_DECISION,
+ data={"taskId": "another-task", "requestId": data["requestId"], "action": "skip"},
+ ))
+ Dispatcher.dispatch(WSEnvelope(
+ id=id,
+ type=protocol.BETTERGI_INSTALL_DECISION,
+ data={"taskId": data["taskId"], "requestId": data["requestId"], "action": "retry"},
+ ))
+ return True
+
+ monkeypatch.setattr(install_recovery.Publisher, "send", send)
+ action, snapshot = await install_recovery.ask_install_recovery(
+ "task1", root, channel="stable", takeover_enabled=False
+ )
+ assert action == "retry"
+ assert snapshot == marker.read_bytes()
+ assert outgoing == [protocol.BETTERGI_INSTALL_PROMPT, protocol.BETTERGI_INSTALL_PROMPT_CLOSED]
+
+
+@pytest.mark.asyncio
+async def test_recovery_prompt_timeout_never_deletes_marker(installation, monkeypatch):
+ from app.task.BetterGI.tools import install_recovery
+ from app.core.ws import protocol
+
+ root, _, _ = installation
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}')
+ monkeypatch.setattr(install_recovery, "_PROMPT_TIMEOUT_SECONDS", 0.005)
+ send = AsyncMock(return_value=True)
+ monkeypatch.setattr(install_recovery.Publisher, "send", send)
+ with pytest.raises(script_install.InstallUnsafeError, match="超时"):
+ await install_recovery.ask_install_recovery(
+ "task1", root, channel="stable", takeover_enabled=False
+ )
+ assert marker.exists()
+ assert send.await_args_list[-1].kwargs["type"] == protocol.BETTERGI_INSTALL_PROMPT_CLOSED
From 4625c27a077fe7d303fb2aeb4e6aedfba2183e93 Mon Sep 17 00:00:00 2001
From: youngslin <121206610+youngslin@users.noreply.github.com>
Date: Tue, 10 Nov 2026 07:54:24 +0100
Subject: [PATCH 06/19] style(bettergi): format recovery flow and add changelog
fragment
---
app/task/BetterGI/tools/install_recovery.py | 39 +++++++++----
app/task/BetterGI/tools/program_update.py | 13 +++--
changelog.d/1900.fix.md | 2 +
tests/task/test_bettergi_program_update.py | 63 +++++++++++++++------
4 files changed, 85 insertions(+), 32 deletions(-)
create mode 100644 changelog.d/1900.fix.md
diff --git a/app/task/BetterGI/tools/install_recovery.py b/app/task/BetterGI/tools/install_recovery.py
index 63d04889cd..f3dce2e61f 100644
--- a/app/task/BetterGI/tools/install_recovery.py
+++ b/app/task/BetterGI/tools/install_recovery.py
@@ -34,13 +34,21 @@ async def ask_install_recovery(
"""返回 (retry/skip, 原标记字节),让后续操作对同一份安装记录做乐观校验。"""
marker = script_install.workspace(root) / "bgi-install.json"
if marker.is_symlink() or not marker.is_file():
- raise script_install.InstallUnsafeError("BetterGI 安装恢复记录不是普通文件,请保留现场")
+ raise script_install.InstallUnsafeError(
+ "BetterGI 安装恢复记录不是普通文件,请保留现场"
+ )
snapshot = await asyncio.to_thread(marker.read_bytes)
if len(snapshot) > 64 * 1024:
- raise script_install.InstallUnsafeError("BetterGI 安装标记异常,请手动检查安装状态")
+ raise script_install.InstallUnsafeError(
+ "BetterGI 安装标记异常,请手动检查安装状态"
+ )
try:
content = json.loads(snapshot)
- target = str(content.get("target", "未知"))[:64] if isinstance(content, dict) else "未知"
+ target = (
+ str(content.get("target", "未知"))[:64]
+ if isinstance(content, dict)
+ else "未知"
+ )
except (ValueError, UnicodeDecodeError):
target = "未知"
@@ -96,8 +104,11 @@ def receive(envelope) -> None:
raise script_install.InstallUnsafeError(
"用户取消 BetterGI 安装恢复,本次任务已停止;未修改安装标记"
)
- if (marker.is_symlink() or not marker.is_file()
- or await asyncio.to_thread(marker.read_bytes) != snapshot):
+ if (
+ marker.is_symlink()
+ or not marker.is_file()
+ or await asyncio.to_thread(marker.read_bytes) != snapshot
+ ):
raise script_install.InstallUnsafeError(
"BetterGI 安装状态在等待选择时发生变化,本次任务已停止,请重新检查"
)
@@ -137,8 +148,11 @@ async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str:
# 不能只看安装目录:官方安装器可能从用户的下载文件夹启动。
await asyncio.to_thread(program_update.assert_idle, root)
- if (marker.is_symlink() or not marker.is_file()
- or await asyncio.to_thread(marker.read_bytes) != snapshot):
+ if (
+ marker.is_symlink()
+ or not marker.is_file()
+ or await asyncio.to_thread(marker.read_bytes) != snapshot
+ ):
raise script_install.InstallUnsafeError(
"BetterGI 安装恢复记录已经变化,本次不清理,请重新检查"
)
@@ -156,8 +170,11 @@ async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str:
# 版本及架构检查不保证所有附属文件完整;这是用户主动接受的风险。
await asyncio.to_thread(program_update.assert_idle, root)
- if (marker.is_symlink() or not marker.is_file()
- or await asyncio.to_thread(marker.read_bytes) != snapshot):
+ if (
+ marker.is_symlink()
+ or not marker.is_file()
+ or await asyncio.to_thread(marker.read_bytes) != snapshot
+ ):
raise script_install.InstallUnsafeError("BetterGI 安装记录已变化,拒绝跳过")
marker.unlink()
try:
@@ -170,5 +187,7 @@ async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str:
except OSError as error:
logger.error(f"BetterGI 无法恢复安装保护标记:{error}")
raise
- logger.warning(f"用户确认跳过 BetterGI 未完成安装,已清除原标记;当前版本={version}")
+ logger.warning(
+ f"用户确认跳过 BetterGI 未完成安装,已清除原标记;当前版本={version}"
+ )
return str(version)
diff --git a/app/task/BetterGI/tools/program_update.py b/app/task/BetterGI/tools/program_update.py
index 86095ec92c..934a9c1544 100644
--- a/app/task/BetterGI/tools/program_update.py
+++ b/app/task/BetterGI/tools/program_update.py
@@ -53,9 +53,12 @@ def assert_idle(root: Path) -> None:
# 安装器可以由用户从下载目录手动启动,不一定在 root/workspace 内。
# 只要存在同名安装器就不能在恢复时清除标记。
exe_name = Path(executable).name.casefold() if executable else ""
- if (name == "bettergi.update.exe" or name.startswith("bettergi.install.")
- or exe_name == "bettergi.update.exe"
- or exe_name.startswith("bettergi.install.")):
+ if (
+ name == "bettergi.update.exe"
+ or name.startswith("bettergi.install.")
+ or exe_name == "bettergi.update.exe"
+ or exe_name.startswith("bettergi.install.")
+ ):
raise script_install.InstallUnsafeError(
"BetterGI 安装器仍在运行,请先结束安装或退出安装器"
)
@@ -405,7 +408,9 @@ async def update_before_run(
try:
await run_installer(installer, root)
except BaseException as error:
- logger.warning(f"BetterGI 安装未确认,保留标记:{type(error).__name__}: {error}")
+ logger.warning(
+ f"BetterGI 安装未确认,保留标记:{type(error).__name__}: {error}"
+ )
raise
await asyncio.to_thread(assert_idle, root)
if await asyncio.to_thread(read_version, root) != target:
diff --git a/changelog.d/1900.fix.md b/changelog.d/1900.fix.md
new file mode 100644
index 0000000000..4a04653267
--- /dev/null
+++ b/changelog.d/1900.fix.md
@@ -0,0 +1,2 @@
+project: bgi
+修复 BetterGI 更新中断后残留安装记录导致任务无法继续的问题
diff --git a/tests/task/test_bettergi_program_update.py b/tests/task/test_bettergi_program_update.py
index f5df20a9f5..388ede2200 100644
--- a/tests/task/test_bettergi_program_update.py
+++ b/tests/task/test_bettergi_program_update.py
@@ -587,7 +587,10 @@ async def test_skip_with_takeover_on_does_not_install_again(installation, monkey
await instance._prepare_program_update()
assert not marker.exists()
update.request_release.assert_not_awaited()
- assert json.loads((root / "User/config.json").read_text())[update.NOTICE_KEY] == update.IGNORED_VERSION
+ assert (
+ json.loads((root / "User/config.json").read_text())[update.NOTICE_KEY]
+ == update.IGNORED_VERSION
+ )
update.recover_update_notice(root)
@@ -717,21 +720,39 @@ async def test_recovery_prompt_accepts_only_matching_request(installation, monke
async def send(*, id, type, data):
outgoing.append(type)
if type == protocol.BETTERGI_INSTALL_PROMPT:
- Dispatcher.dispatch(WSEnvelope(
- id=id,
- type=protocol.BETTERGI_INSTALL_DECISION,
- data={"taskId": data["taskId"], "requestId": "wrong", "action": "skip"},
- ))
- Dispatcher.dispatch(WSEnvelope(
- id=id,
- type=protocol.BETTERGI_INSTALL_DECISION,
- data={"taskId": "another-task", "requestId": data["requestId"], "action": "skip"},
- ))
- Dispatcher.dispatch(WSEnvelope(
- id=id,
- type=protocol.BETTERGI_INSTALL_DECISION,
- data={"taskId": data["taskId"], "requestId": data["requestId"], "action": "retry"},
- ))
+ Dispatcher.dispatch(
+ WSEnvelope(
+ id=id,
+ type=protocol.BETTERGI_INSTALL_DECISION,
+ data={
+ "taskId": data["taskId"],
+ "requestId": "wrong",
+ "action": "skip",
+ },
+ )
+ )
+ Dispatcher.dispatch(
+ WSEnvelope(
+ id=id,
+ type=protocol.BETTERGI_INSTALL_DECISION,
+ data={
+ "taskId": "another-task",
+ "requestId": data["requestId"],
+ "action": "skip",
+ },
+ )
+ )
+ Dispatcher.dispatch(
+ WSEnvelope(
+ id=id,
+ type=protocol.BETTERGI_INSTALL_DECISION,
+ data={
+ "taskId": data["taskId"],
+ "requestId": data["requestId"],
+ "action": "retry",
+ },
+ )
+ )
return True
monkeypatch.setattr(install_recovery.Publisher, "send", send)
@@ -740,7 +761,10 @@ async def send(*, id, type, data):
)
assert action == "retry"
assert snapshot == marker.read_bytes()
- assert outgoing == [protocol.BETTERGI_INSTALL_PROMPT, protocol.BETTERGI_INSTALL_PROMPT_CLOSED]
+ assert outgoing == [
+ protocol.BETTERGI_INSTALL_PROMPT,
+ protocol.BETTERGI_INSTALL_PROMPT_CLOSED,
+ ]
@pytest.mark.asyncio
@@ -760,4 +784,7 @@ async def test_recovery_prompt_timeout_never_deletes_marker(installation, monkey
"task1", root, channel="stable", takeover_enabled=False
)
assert marker.exists()
- assert send.await_args_list[-1].kwargs["type"] == protocol.BETTERGI_INSTALL_PROMPT_CLOSED
+ assert (
+ send.await_args_list[-1].kwargs["type"]
+ == protocol.BETTERGI_INSTALL_PROMPT_CLOSED
+ )
From 38d4a7d09e7b9e7568f22ca794293c72567b8cfd Mon Sep 17 00:00:00 2001
From: youngslin <121206610+youngslin@users.noreply.github.com>
Date: Tue, 10 Nov 2026 07:57:06 +0100
Subject: [PATCH 07/19] style(bettergi): normalize frontend formatting and make
recovery dialog scrollable
---
app/task/BetterGI/tools/install_recovery.py | 4 +-
.../BetterGIInstallRecoveryModal.vue | 44 +++++++++++++++----
.../src/services/bettergiInstallRecovery.ts | 23 +++++++---
3 files changed, 55 insertions(+), 16 deletions(-)
diff --git a/app/task/BetterGI/tools/install_recovery.py b/app/task/BetterGI/tools/install_recovery.py
index f3dce2e61f..9e565c0208 100644
--- a/app/task/BetterGI/tools/install_recovery.py
+++ b/app/task/BetterGI/tools/install_recovery.py
@@ -54,7 +54,9 @@ async def ask_install_recovery(
try:
installed = str(await asyncio.to_thread(program_update.read_version, root))
- except (OSError, ValueError, ImportError, IndexError, KeyError):
+ except Exception as error:
+ # 元数据不可读时仍允许用户选择重试;跳过阶段才严格校验。
+ logger.warning(f"读取 BetterGI 当前版本失败,恢复提示显示未知:{error}")
installed = "未知"
request_id = uuid4().hex
diff --git a/frontend/src/components/BetterGIInstallRecoveryModal.vue b/frontend/src/components/BetterGIInstallRecoveryModal.vue
index 92643a88b5..0dd1e89b20 100644
--- a/frontend/src/components/BetterGIInstallRecoveryModal.vue
+++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue
@@ -11,12 +11,17 @@ const { t } = useI18n()
const prompt = computed(() => bettergiInstallPrompts.value[0] ?? null)
const acknowledged = ref(false)
const sendFailed = ref(false)
-const submitting = computed(() => bettergiInstallDecisionPending.value === prompt.value?.requestId)
+const submitting = computed(
+ () => bettergiInstallDecisionPending.value === prompt.value?.requestId
+)
-watch(() => prompt.value?.requestId, () => {
- acknowledged.value = false
- sendFailed.value = false
-})
+watch(
+ () => prompt.value?.requestId,
+ () => {
+ acknowledged.value = false
+ sendFailed.value = false
+ }
+)
function decide(action: 'retry' | 'skip' | 'stop') {
if (submitting.value || (action === 'skip' && !acknowledged.value)) return
@@ -34,6 +39,7 @@ function decide(action: 'retry' | 'skip' | 'stop') {
:footer="null"
:z-index="900"
:width="560"
+ class="bettergi-recovery-modal"
centered
>
@@ -46,10 +52,20 @@ function decide(action: 'retry' | 'skip' | 'stop') {
{{ prompt.targetVersion || t('bootstrap.comp.bettergiRecoveryUnknown') }}
- {{ prompt.channel === 'stable' ? t('bootstrap.comp.bettergiRecoveryStable') : t('bootstrap.comp.bettergiRecoveryBeta') }}
+ {{
+ prompt.channel === 'stable'
+ ? t('bootstrap.comp.bettergiRecoveryStable')
+ : t('bootstrap.comp.bettergiRecoveryBeta')
+ }}
- {{ t(prompt.takeoverEnabled ? 'bootstrap.comp.bettergiRecoveryEnabled' : 'bootstrap.comp.bettergiRecoveryDisabled') }}
+ {{
+ t(
+ prompt.takeoverEnabled
+ ? 'bootstrap.comp.bettergiRecoveryEnabled'
+ : 'bootstrap.comp.bettergiRecoveryDisabled'
+ )
+ }}
-
+
{{ t('bootstrap.comp.bettergiRecoveryAcknowledgement') }}