Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
76a62cf
feat(bettergi): add bounded prompt for unresolved installation
youngslin Nov 10, 2026
f338301
fix(bettergi): route interrupted installs through guarded user recovery
youngslin Nov 10, 2026
cb299c5
feat(bettergi): show recovery choice in all launch modes
youngslin Nov 10, 2026
ed2814d
fix(bettergi): translate stable and beta recovery channel labels
youngslin Nov 10, 2026
6cf5a94
test(bettergi): cover skip, retry, mark races, installer and prompt f…
youngslin Nov 10, 2026
4625c27
style(bettergi): format recovery flow and add changelog fragment
youngslin Nov 10, 2026
38d4a7d
style(bettergi): normalize frontend formatting and make recovery dial…
youngslin Nov 10, 2026
ed08ec1
style(bettergi): align short frontend lines with 100-char formatter w…
youngslin Nov 10, 2026
5c7e40d
test(bettergi): cover prompt queue, acknowledgement transport, and di…
youngslin Nov 10, 2026
3528adf
style(bettergi): simplify frontend test typings for formatter
youngslin Nov 10, 2026
38f1621
test(bettergi): defer frontend-only harness while keeping backend reg…
youngslin Nov 10, 2026
2884562
fix(bettergi): repair corrupted installs and identify recovery target
youngslin Nov 10, 2026
6242be6
merge(dev): integrate BetterGI stable resource gate with install reco…
youngslin Nov 10, 2026
1912799
style(bettergi): format recovery fixes to pass Ruff
youngslin Nov 10, 2026
e6811d5
test(bettergi): fix recovery stub and format dialog
youngslin Nov 10, 2026
4a91d8f
fix(bettergi): allow broken executable into ScriptConfig recovery prompt
youngslin Nov 10, 2026
d76c321
style: align recovery changes with Ruff formatting
youngslin Nov 10, 2026
96e2ae4
test(bettergi): configure legacy execution mode through task info
youngslin Nov 10, 2026
9ecb6fb
fix(bettergi): discard outdated install prompts after active reconnect
youngslin Nov 10, 2026
cb1273a
style: format reconnect regression test
youngslin Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/core/ws/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@
TASK_SCRIPT_STARTED = "task.script.started"
TASK_SCRIPT_FINISHED = "task.script.finished"

# BetterGI 上次安装未确认时的单次交互请求与关闭(id=TaskManager)
BETTERGI_INSTALL_PROMPT = "bettergi.install.prompt"
BETTERGI_INSTALL_PROMPT_CLOSED = "bettergi.install.prompt.closed"
BETTERGI_INSTALL_DECISION = "bettergi.install.decision"

# 应用生命周期与电源(id=Main)
BACKEND_SHUTDOWN_READY = "backend.shutdown.ready"
# 启动期攒下来、主连接建立后一次性发出的系统通知(id=Main):配置迁移结果之类。
Expand Down
107 changes: 86 additions & 21 deletions app/task/BetterGI/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with AUTO-MAS. If not, see <https://www.gnu.org/licenses/>.
import asyncio
import uuid
from contextlib import ExitStack, suppress
from datetime import datetime
Expand All @@ -36,7 +37,7 @@

from .AutoProxy import _BGI_REL_EXE, AutoProxyTask
from .ScriptConfig import ScriptConfigTask
from .tools import archive_native_backup, program_update
from .tools import archive_native_backup, install_recovery, program_update
from .Update import BetterGIUpdateTask

logger = get_logger("BetterGI 调度器")
Expand Down Expand Up @@ -77,8 +78,14 @@
if not script_config.Info.RootPath:
return "请先设置有效的 BetterGI 脚本路径"
root_path = script_config.Info.RootPath
if not root_path.is_dir() or not (root_path / _BGI_REL_EXE).is_file():
if not root_path.is_dir():
return "请先设置有效的 BetterGI 脚本路径"
if not (root_path / _BGI_REL_EXE).is_file():
marker = script_install.workspace(root_path) / "bgi-install.json"
# 唯有上轮安装保护标记存在时,允许进入完整安装恢复询问。
# 无标记的失效路径仍然拒绝,不能直接打开损坏程序。
if marker.is_symlink() or not marker.is_file():
return "请先设置有效的 BetterGI 脚本路径"
target_user_id = self.task_info.user_id or "Default"
if target_user_id != "Default":
try:
Expand Down Expand Up @@ -167,6 +174,62 @@
self.build_proxy_user_list(self.user_config.items())
)

async def _run_program_update(
self, root: Path, *, recovery_snapshot: bytes | None = None
) -> str:
await self.script_info.set_log("正在检查 BetterGI 本体更新…")

async def progress(downloaded: int, total: int, speed: float) -> None:
await self.script_info.set_log(
f"BetterGI 更新下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB"
)

return await run_script_update(
ScriptUpdateRequest(self.script_info.script_id, "BetterGI"),
program_update.update_before_run,
root,
self.script_config,
progress=progress,
recovery_snapshot=recovery_snapshot,
)

async def _recover_unconfirmed_install(self) -> bool:
"""残留标记由用户明确决定;关闭自动接管时也允许主动重试一次。"""
root = self._update_root
if root is None:
return False
marker = script_install.workspace(root) / "bgi-install.json"
if not marker.exists() and not marker.is_symlink():
return False

# 持有安装锁,但还须排除 MAS 之外手工启动的安装器和运行中的 BGI。
await asyncio.to_thread(program_update.assert_idle, root)
action, snapshot = await install_recovery.ask_install_recovery(
self.task_info.task_id,
root,
managed_name=getattr(self.script_info, "name", "BetterGI"),
channel=self.script_config.Update.Channel,
takeover_enabled=self.script_config.Update.TakeoverEnabled,
)
if action == "skip":
version = await install_recovery.skip_unconfirmed_install(root, snapshot)
result = f"用户确认跳过 BetterGI 未完成安装,当前版本 {version},继续任务"
logger.warning(result)
await self.script_info.set_log(result)
with suppress(Exception):
await Publisher.send(
id=self.task_info.task_id,
type=protocol.TASK_NOTICE,
data=WSTaskNoticeData(level="warning", message=result),
)
else:
# 用户主动选择重试:即使 TakeoverEnabled=False,也只为本次恢复调用更新器。
# 不走普通更新失败的旧版回退:安装标记仍在时任何失败都必须停止。
result = await self._run_program_update(root, recovery_snapshot=snapshot)
await program_update.confirm_current_install(root)
await self.script_info.set_log(f"BetterGI 安装恢复成功:{result}")
return True

async def _prepare_program_update(self) -> None:
root = self._update_root
if root is None:
Expand All @@ -175,24 +238,24 @@
if self._program_update_prepared:
if self.script_config.Update.TakeoverEnabled:
await program_update.confirm_current_install(root)
elif (script_install.workspace(root) / "bgi-install.json").exists():
raise script_install.InstallUnsafeError(
"BetterGI 安装状态在任务期间发生变化,已停止启动"
)
self.check_result = "Pass"
return
if self.script_config.Update.TakeoverEnabled:
await self.script_info.set_log("正在检查 BetterGI 本体更新…")

async def progress(downloaded: int, total: int, speed: float) -> None:
await self.script_info.set_log(
f"BetterGI 更新下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB"
)
if await self._recover_unconfirmed_install():
# 无论重试还是跳过,本轮已由用户确定更新动作;不能继续自动更新第二次。
if self.script_config.Update.TakeoverEnabled:
program_update.suppress_update_notice(root)
self._program_update_prepared = True
self.check_result = "Pass"
return

if self.script_config.Update.TakeoverEnabled:
try:
result = await run_script_update(
ScriptUpdateRequest(self.script_info.script_id, "BetterGI"),
program_update.update_before_run,
root,
self.script_config,
progress=progress,
)
result = await self._run_program_update(root)
except (script_install.InstallUnsafeError, FluxdownCancelUnconfirmed):
raise
except Exception as error:
Expand All @@ -208,11 +271,11 @@
await self.script_info.set_log(result)
program_update.suppress_update_notice(root)
else:
# 关闭接管仍不能启动上轮未确认安装;不发远程请求。
# 关闭自动接管不等于自动忽略上次更新记录;状态变化时保守停止。
marker = script_install.workspace(root) / "bgi-install.json"
if marker.exists():
raise script_install.InstallUnsafeError(
"BetterGI 上次安装未确认,请开启更新托管重试或手动修复"
"BetterGI 上次安装未确认,无法直接启动"
)
self._program_update_prepared = True
self.check_result = "Pass"
Expand All @@ -237,10 +300,12 @@
# Update 只更新原神客户端,不启动 BetterGI;运行中的正常 BGI
# 不应该阻断游戏更新,但未完成的本体安装必须阻断。
root = self._update_root
if root is not None and self.execution_mode == "Update":
await program_update.confirm_safe_game_update(root)
elif root is not None and self.execution_mode == "ScriptConfig":
await program_update.confirm_current_install(root)
if root is not None and self.execution_mode in ("Update", "ScriptConfig"):

Check warning on line 303 in app/task/BetterGI/manager.py

View workflow job for this annotation

GitHub Actions / 检查是否删掉了他人近期合入的代码

可能回退了 #1845 的改动

这里删掉了 #1845(DLmaster,2026-11-09 04:57 UTC 合入)加入的 2 行(原第 240、242 行)。如果不是有意修改,多半是同步分支时带回了旧版本的文件。
await self._recover_unconfirmed_install()
if self.execution_mode == "Update":
await program_update.confirm_safe_game_update(root)
else:
await program_update.confirm_current_install(root)

# 「检查更新」不动 BetterGI 配置,不必归档,直接进更新任务
if self.execution_mode == "Update":
Expand Down
198 changes: 198 additions & 0 deletions app/task/BetterGI/tools/install_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2025-2026 AUTO-MAS Team
# SPDX-License-Identifier: AGPL-3.0-or-later

"""BetterGI 未确认本体安装的交互恢复。

等待选择时持有专项原有安装锁;没有前端、超时、取消或连接丢失都不默认放行。
本模块只收集一次显式决定,实际清除标记及重新安装仍由专项执行并复核。
"""

import asyncio
import json
from pathlib import Path
from uuid import uuid4

from app.core import Dispatcher, Publisher
from app.core.ws import protocol
from app.services import script_install
from app.utils import get_logger

from . import program_update

logger = get_logger("BetterGI 安装恢复")
_PROMPT_TIMEOUT_SECONDS = 120


async def ask_install_recovery(
task_id: str,
root: Path,
*,
channel: str,
takeover_enabled: bool,
managed_name: str = "BetterGI",
) -> tuple[str, bytes]:
"""返回 (retry/skip, 原标记字节),让后续操作对同一份安装记录做乐观校验。"""
marker = script_install.workspace(root) / "bgi-install.json"
if marker.is_symlink() or not marker.is_file():
raise script_install.InstallUnsafeError(
"BetterGI 安装恢复记录不是普通文件,请保留现场"
)
snapshot = await asyncio.to_thread(marker.read_bytes)
if len(snapshot) > 64 * 1024:
raise script_install.InstallUnsafeError(
"BetterGI 安装标记异常,请手动检查安装状态"
)
try:
content = json.loads(snapshot)
target = (
str(content.get("target", "未知"))[:64]
if isinstance(content, dict)
else "未知"
)
except (ValueError, UnicodeDecodeError):
target = "未知"

try:
installed = str(await asyncio.to_thread(program_update.read_version, root))
except Exception as error:
# 元数据不可读时仍允许用户选择重试;跳过阶段才严格校验。
logger.warning(f"读取 BetterGI 当前版本失败,恢复提示显示未知:{error}")
installed = "未知"
Comment on lines +56 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Retrying an unconfirmed install recovery when BetterGI.exe is missing or corrupt raises unhandled exceptions from read_version and fails repeatedly.
Severity: MEDIUM

Suggested Fix

Add exception handling around read_version and read_pe_architecture within update_before_run, or catch exceptions during the retry branch in _recover_unconfirmed_install and handle corrupted or missing executables gracefully (e.g., treating them as requiring a fresh download rather than crashing).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: app/task/BetterGI/tools/install_recovery.py#L55-L60

Potential issue: When handling an unconfirmed installation recovery where the executable
is missing or corrupt, `ask_install_recovery` allows the user to choose 'retry' even
when version metadata cannot be read. However, selecting 'retry' invokes
`_run_program_update` and `update_before_run`, which call `read_version` and
`read_pe_architecture` on `BetterGI.exe` without exception handling. In this corrupted
or missing binary state, `win32api.GetFileVersionInfo` raises unhandled exceptions such
as `pywintypes.error` that escape `_recover_unconfirmed_install`. Consequently, the task
terminates abruptly, the installation marker remains uncleared, and subsequent attempts
repeatedly prompt the user with a retry option that cannot succeed.

Also affects:

  • app/task/BetterGI/manager.py:215~221

Did we get this right? 👍 / 👎 to inform future reviews.


request_id = uuid4().hex
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()

def receive(envelope) -> None:
data = envelope.data
if (
data.get("requestId") == request_id
and data.get("taskId") == task_id
and data.get("action") in ("retry", "skip", "stop")
and not future.done()
):
future.set_result(data["action"])

unregister = Dispatcher.register(
protocol.ID_TASK_MANAGER, protocol.BETTERGI_INSTALL_DECISION, receive
)
try:
sent = await Publisher.send(
id=protocol.ID_TASK_MANAGER,
type=protocol.BETTERGI_INSTALL_PROMPT,
data={
"requestId": request_id,
"taskId": task_id,
"channel": channel,
"takeoverEnabled": takeover_enabled,
"installedVersion": installed,
"targetVersion": target,
"managedName": managed_name,
"installPath": str(root.resolve()),
},
)
if not sent:
raise script_install.InstallUnsafeError(
"BetterGI 上次安装未确认;未连接主界面,无法获取恢复选择,请打开 MAS 界面重试"
)
logger.warning(
f"BetterGI 安装未确认,等待用户选择:当前={installed},"
f"原目标={target},通道={channel},自动接管={takeover_enabled}"
)
try:
action = await asyncio.wait_for(future, timeout=_PROMPT_TIMEOUT_SECONDS)
except TimeoutError as error:
raise script_install.InstallUnsafeError(
"BetterGI 安装恢复选择超时,本次任务已停止;未修改安装标记"
) from error
if action == "stop":
raise script_install.InstallUnsafeError(
"用户取消 BetterGI 安装恢复,本次任务已停止;未修改安装标记"
)
if (
marker.is_symlink()
or not marker.is_file()
or await asyncio.to_thread(marker.read_bytes) != snapshot
):
raise script_install.InstallUnsafeError(
"BetterGI 安装状态在等待选择时发生变化,本次任务已停止,请重新检查"
)
logger.warning(f"用户选择 BetterGI 安装恢复操作:{action}")
return action, snapshot
finally:
unregister()
# 让超时、手动停止及任务取消的旧弹窗失效;迟到回复不会再被接受。
try:
await Publisher.send(
id=protocol.ID_TASK_MANAGER,
type=protocol.BETTERGI_INSTALL_PROMPT_CLOSED,
data={"requestId": request_id},
)
except Exception:
logger.warning("发送 BetterGI 安装恢复弹窗关闭通知失败")


async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str:
"""在用户明确确认后跳过一次残留安装;先核查程序,最后清除同一份标记。

只允许拥有安装锁的 BetterGIManager 调用。无法证明进程停止、标记未变、
当前可执行文件版本/架构可识别时,不能清理标记。
"""
from packaging.version import Version

marker = script_install.workspace(root) / "bgi-install.json"
try:
record = json.loads(snapshot)
if not isinstance(record, dict) or not isinstance(record.get("target"), str):
raise ValueError("安装标记格式不正确")
Version(record["target"])
except (ValueError, TypeError) as error:
raise script_install.InstallUnsafeError(
"BetterGI 安装标记内容异常,不能直接跳过,请使用官方安装器修复"
) from error

# 不能只看安装目录:官方安装器可能从用户的下载文件夹启动。
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
):
raise script_install.InstallUnsafeError(
"BetterGI 安装恢复记录已经变化,本次不清理,请重新检查"
)
try:
version = await asyncio.to_thread(program_update.read_version, root)
arch = await asyncio.to_thread(
script_install.read_pe_architecture, root / "BetterGI.exe"
)
if arch != "x86_64":
raise ValueError("程序架构不可识别")
except Exception as error:
raise script_install.InstallUnsafeError(
"BetterGI 当前程序版本或架构不可识别,不能跳过,请先手动修复"
) from error

# 版本及架构检查不保证所有附属文件完整;这是用户主动接受的风险。
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
):
raise script_install.InstallUnsafeError("BetterGI 安装记录已变化,拒绝跳过")
marker.unlink()
try:
await program_update.confirm_current_install(root)
except BaseException:
# 最后的退出/版本复核失败时,尽可能恢复原标记,避免失去安全门禁。
if not marker.exists():
try:
marker.write_bytes(snapshot)
except OSError as error:
logger.error(f"BetterGI 无法恢复安装保护标记:{error}")
raise
logger.warning(
f"用户确认跳过 BetterGI 未完成安装,已清除原标记;当前版本={version}"
)
return str(version)
Loading
Loading