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..a99fe38e6c
--- /dev/null
+++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+ {{ t('bootstrap.comp.bettergiRecoveryMessage') }}
+
+
+ {{ prompt.managedName || t('bootstrap.comp.bettergiRecoveryUnknown') }}
+
+
+ {{
+ prompt.installPath || t('bootstrap.comp.bettergiRecoveryUnknown')
+ }}
+
+
+ {{ prompt.installedVersion || t('bootstrap.comp.bettergiRecoveryUnknown') }}
+
+
+ {{ prompt.targetVersion || t('bootstrap.comp.bettergiRecoveryUnknown') }}
+
+
+ {{
+ prompt.channel === 'stable'
+ ? t('bootstrap.comp.bettergiRecoveryStable')
+ : t('bootstrap.comp.bettergiRecoveryBeta')
+ }}
+
+
+ {{
+ 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..b6f915a0b1 100644
--- a/frontend/src/i18n/bootstrap/en-US.json
+++ b/frontend/src/i18n/bootstrap/en-US.json
@@ -3,6 +3,25 @@
"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.",
+ "bettergiRecoveryManagedName": "Managed profile",
+ "bettergiRecoveryInstallPath": "Installation folder",
+ "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..f692b42992 100644
--- a/frontend/src/i18n/bootstrap/ja-JP.json
+++ b/frontend/src/i18n/bootstrap/ja-JP.json
@@ -3,6 +3,25 @@
"languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。"
},
"comp": {
+ "bettergiRecoveryStable": "正式版",
+ "bettergiRecoveryBeta": "ベータ版",
+ "bettergiRecoveryTitle": "BetterGI の前回インストールが未確認です",
+ "bettergiRecoveryMessage": "前回の BetterGI 更新が完了したことを確認できません。配布ファイルの公開が遅れているか、インストールが中断された可能性があります。今回の処理を選択してください。",
+ "bettergiRecoveryManagedName": "管理設定名",
+ "bettergiRecoveryInstallPath": "インストール先",
+ "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..3039100940 100644
--- a/frontend/src/i18n/bootstrap/zh-CN.json
+++ b/frontend/src/i18n/bootstrap/zh-CN.json
@@ -3,6 +3,25 @@
"languageSaveFailed": "语言设置保存失败,已恢复原语言"
},
"comp": {
+ "bettergiRecoveryStable": "正式版",
+ "bettergiRecoveryBeta": "公测版",
+ "bettergiRecoveryTitle": "BetterGI 上次安装未完成",
+ "bettergiRecoveryMessage": "检测到上一次 BetterGI 更新的未确认记录。可能是新版本安装资源尚未分发完成,或安装中途取消。请选择如何处理本次任务。",
+ "bettergiRecoveryManagedName": "托管名称",
+ "bettergiRecoveryInstallPath": "安装目录",
+ "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.test.ts b/frontend/src/services/bettergiInstallRecovery.test.ts
new file mode 100644
index 0000000000..5c5e59a057
--- /dev/null
+++ b/frontend/src/services/bettergiInstallRecovery.test.ts
@@ -0,0 +1,86 @@
+import { afterEach, expect, it, vi } from 'vite-plus/test'
+
+const boundary = vi.hoisted(() => ({
+ subscriptions: new Map
}) => void>(),
+ connected: [] as Array<() => void>,
+ disconnected: [] as Array<() => void>,
+ send: vi.fn(() => true),
+}))
+
+vi.mock('@/services/websocket/subscriptions', () => ({
+ subscribe: (
+ criteria: { id: string; type: string },
+ handler: (message: { data: Record }) => void
+ ) => {
+ const key = `${criteria.id}:${criteria.type}`
+ boundary.subscriptions.set(key, handler)
+ return key
+ },
+ unsubscribe: (id: string) => {
+ boundary.subscriptions.delete(id)
+ },
+}))
+
+vi.mock('@/services/websocket/connection', () => ({
+ send: boundary.send,
+ onConnected: (listener: () => void) => {
+ boundary.connected.push(listener)
+ return () => {
+ const at = boundary.connected.indexOf(listener)
+ if (at >= 0) boundary.connected.splice(at, 1)
+ }
+ },
+ onDisconnected: (listener: () => void) => {
+ boundary.disconnected.push(listener)
+ return () => {
+ const at = boundary.disconnected.indexOf(listener)
+ if (at >= 0) boundary.disconnected.splice(at, 1)
+ }
+ },
+}))
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ boundary.subscriptions.clear()
+ boundary.connected.length = 0
+ boundary.disconnected.length = 0
+ boundary.send.mockClear()
+})
+
+it('drops stale BetterGI install prompt after active websocket reconnect without close event', async () => {
+ vi.stubGlobal('window', {
+ electronAPI: { getLogger: () => ({ warn: vi.fn() }) },
+ })
+ const module = await import('./bettergiInstallRecovery')
+ module.bootstrapBetterGIInstallRecovery()
+ const prompt = boundary.subscriptions.get('TaskManager:bettergi.install.prompt')
+ expect(prompt).toBeTruthy()
+
+ prompt!({
+ data: {
+ requestId: 'old-request',
+ taskId: 'task-one',
+ managedName: 'Example',
+ installPath: '/sandbox/one',
+ },
+ })
+ expect(module.bettergiInstallPrompts.value).toHaveLength(1)
+ expect(boundary.connected).toHaveLength(1)
+
+ // Simulate reconnectNow successful: stale socket close was intentionally ignored,
+ // and backend's final prompt-closed frame never arrived.
+ boundary.connected[0]()
+ expect(module.bettergiInstallPrompts.value).toHaveLength(0)
+ expect(module.bettergiInstallDecisionPending.value).toBeNull()
+ expect(module.respondToBetterGIInstallRecovery('skip')).toBe(false)
+ expect(boundary.send).not.toHaveBeenCalled()
+
+ // A new prompt from the new transport remains actionable.
+ prompt!({ data: { requestId: 'new-request', taskId: 'task-two' } })
+ expect(module.respondToBetterGIInstallRecovery('stop')).toBe(true)
+ expect(boundary.send).toHaveBeenCalledOnce()
+
+ module.disposeBetterGIInstallRecovery()
+ expect(boundary.connected).toHaveLength(0)
+ expect(boundary.disconnected).toHaveLength(0)
+})
diff --git a/frontend/src/services/bettergiInstallRecovery.ts b/frontend/src/services/bettergiInstallRecovery.ts
new file mode 100644
index 0000000000..5f919594ec
--- /dev/null
+++ b/frontend/src/services/bettergiInstallRecovery.ts
@@ -0,0 +1,86 @@
+// 应用级 BetterGI 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。
+// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。
+
+import { ref } from 'vue'
+import { onConnected, 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
+let disposeConnect: (() => 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
+ })
+ // reconnectNow 在切换连接代次时不会触发 onDisconnected。
+ // 新连接建立后,旧连接的关闭帧可能已丢失;绝不可沿用旧确认请求。
+ disposeConnect = onConnected(() => {
+ 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
+ disposeConnect?.()
+ disposeConnect = null
+ bettergiInstallPrompts.value = []
+ bettergiInstallDecisionPending.value = null
+}
diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts
index d7f0005eba..89308427b4 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,22 @@ 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
+ managedName: string
+ installPath: string
+}
+
+export interface WSBetterGIInstallPromptClosedData {
+ requestId: string
+}
+
/** 新任务创建通知数据 (id=TaskManager, type=task.created) */
export interface WSTaskCreatedData {
trigger?: TaskTriggerReason
@@ -329,6 +350,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
diff --git a/tests/task/test_bettergi_program_update.py b/tests/task/test_bettergi_program_update.py
index ef68638364..4d0f302de0 100644
--- a/tests/task/test_bettergi_program_update.py
+++ b/tests/task/test_bettergi_program_update.py
@@ -226,7 +226,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
@@ -240,10 +240,19 @@ 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()))
+ instance.script_info = SimpleNamespace(name="BetterGI 测试托管")
+ 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
@@ -532,6 +541,380 @@ async def download(url, destination, **kwargs):
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
+ )
+
+
+@pytest.mark.asyncio
+async def test_retry_with_corrupted_old_executable_reinstalls_full_package(
+ 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=("retry", marker.read_bytes())),
+ )
+
+ working = [False]
+
+ def version(_root):
+ if not working[0]:
+ raise OSError("unreadable version resource")
+ return Version("0.56.0")
+
+ async def repair(_installer, _root):
+ working[0] = True
+
+ monkeypatch.setattr(update, "read_version", version)
+ update.run_installer.side_effect = repair
+ instance = _recovery_instance(root, config)
+ await instance._prepare_program_update()
+
+ assert instance.check_result == "Pass"
+ assert not marker.exists()
+ update.download_script_package.assert_awaited_once()
+ update.run_installer.assert_awaited_once()
+ await update.confirm_current_install(root)
+
+
+@pytest.mark.asyncio
+async def test_missing_executable_requires_explicit_recovery(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")
+ (root / "BetterGI.exe").unlink()
+
+ monkeypatch.setattr(
+ update, "read_version", lambda _root: (_ for _ in ()).throw(OSError("missing"))
+ )
+ with pytest.raises(OSError, match="missing"):
+ await update.update_before_run(root, config)
+ update.download_script_package.assert_not_awaited()
+ assert marker.exists()
+
+
+@pytest.mark.asyncio
+async def test_recovery_refuses_to_replace_marker_changed_during_download(
+ 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"}')
+ snapshot = marker.read_bytes()
+ original_download = update.download_script_package.side_effect
+
+ async def change_marker(url, destination, **kwargs):
+ await original_download(url, destination, **kwargs)
+ marker.write_text('{"target":"0.57.0"}')
+
+ update.download_script_package.side_effect = change_marker
+ with pytest.raises(script_install.InstallUnsafeError, match="下载期间发生变化"):
+ await update.update_before_run(root, config, recovery_snapshot=snapshot)
+ assert marker.read_text() == '{"target":"0.57.0"}'
+ update.run_installer.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_prompt_identifies_managed_profile_and_installation_root(
+ 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"}')
+ seen = []
+
+ async def send(*, id, type, data):
+ if type == protocol.BETTERGI_INSTALL_PROMPT:
+ seen.append(data)
+ Dispatcher.dispatch(
+ WSEnvelope(
+ id=id,
+ type=protocol.BETTERGI_INSTALL_DECISION,
+ data={
+ "taskId": data["taskId"],
+ "requestId": data["requestId"],
+ "action": "stop",
+ },
+ )
+ )
+ return True
+
+ monkeypatch.setattr(install_recovery.Publisher, "send", send)
+ with pytest.raises(script_install.InstallUnsafeError, match="用户取消"):
+ await install_recovery.ask_install_recovery(
+ "task-id",
+ root,
+ managed_name="托管 B(夜间队列)",
+ channel="stable",
+ takeover_enabled=False,
+ )
+ assert seen[0]["managedName"] == "托管 B(夜间队列)"
+ assert seen[0]["installPath"] == str(root.resolve())
+ assert marker.exists()
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
("notice", "resource", "status", "expected"),
@@ -652,3 +1035,38 @@ async def test_unready_release_does_not_bypass_existing_unconfirmed_install(
await update.update_before_run(root, config)
assert marker.exists()
update.run_installer.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_script_config_missing_executable_with_marker_reaches_recovery_gate(
+ installation, monkeypatch
+):
+ root, config, _ = installation
+ (root / "BetterGI.exe").unlink()
+ marker = script_install.workspace(root) / "bgi-install.json"
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text('{"target": "0.56.0"}')
+ uid = uuid.uuid4()
+ config.Info = SimpleNamespace(RootPath=root)
+ config.UserData = {}
+ monkeypatch.setattr(manager, "BetterGIConfig", type(config))
+ monkeypatch.setattr(manager, "Config", SimpleNamespace(ScriptConfig={uid: config}))
+ instance = object.__new__(manager.BetterGIManager)
+ instance.maintenance = False
+ instance.script_info = SimpleNamespace(script_id=str(uid), name="损坏 BGI")
+ instance.task_info = SimpleNamespace(
+ user_id="Default", task_id="task-id", mode="ScriptConfig"
+ )
+ instance._update_root = root
+ instance.script_config = config
+ monkeypatch.setattr(
+ manager.install_recovery,
+ "ask_install_recovery",
+ AsyncMock(side_effect=script_install.InstallUnsafeError("用户取消恢复")),
+ )
+ assert await instance.check() == "Pass"
+ with pytest.raises(script_install.InstallUnsafeError, match="用户取消恢复"):
+ await instance._recover_unconfirmed_install()
+ assert marker.exists()
+ marker.unlink()
+ assert "有效" in await instance.check()