Skip to content

Commit 313518c

Browse files
committed
fix(updater): 修复软件自更新整条链路
更新流程此前多处断裂,端到端无法完成: - settings_page: check_update 发现新版本后只 emit update_found 未退出 QThread,self._thread 残留导致后续下载被"上一次操作还在进行中"守卫挡掉。 新增 _finalize_current_resource() 在弹窗前同步清理线程并恢复按钮。 - settings_page: _on_download_ready 改走 MainWindow._quit_from_tray 强制 退出路径,而非 app.quit()——后者不设 _force_quit,closeEvent 弹"关闭行为 选择"且 minimize 偏好下 exe 不真退出,bat 等退出循环永远等不到。 - updater: apply_update 启动 bat 加 CREATE_BREAKAWAY_FROM_JOB,脱离 exe 所在 Job 的 kill-on-close;否则 app 退出会连带杀掉 bat。 - updater: bat 等退出循环从 tasklist|findstr 改为 PowerShell Get-Process 单行检测——cmd 管道里的 findstr 在无控制台进程下弹黑窗且卡死、循环反复 spawn。改用文件句柄重定向 bat 输出到 self_update.log(不拼进命令行, 避免路径引号嵌套致 cmd 解析失败)。 - updater: 下载阶段补 logger(开始/连接/每10%里程碑/重试/完成/取消/失败), 此前全程只更新 UI 状态栏不写日志,排查无据。 - afa: 新增 stop_afa(),apply_update 前停掉自带 AFA 独立进程 + bat 兜底 taskkill,避免 afa/AFA.exe 被占用致 robocopy 静默跳过。
1 parent c0446f2 commit 313518c

3 files changed

Lines changed: 170 additions & 15 deletions

File tree

aao/core/afa.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,40 @@ def ensure_afa() -> bool:
8787
except (OSError, subprocess.SubprocessError):
8888
logger.exception("拉起 AFA 失败")
8989
return False
90+
91+
92+
def stop_afa(timeout: float = 5.0) -> bool:
93+
"""停止自带 AFA 进程(自更新前调用,避免 afa/AFA.exe 被占用导致 robocopy 跳过覆盖)。
94+
95+
AFA 是 detached 独立进程,aao.app 退出不会带走它;若更新时不先停掉,
96+
robocopy 覆盖 afa/AFA.exe 会因文件占用而静默跳过(rc 仍 <8 不报错),
97+
用户拿到旧 AFA。
98+
99+
Returns:
100+
True 若已无 AFA 进程(本就没跑或已成功停止);False 若超时仍在。
101+
"""
102+
if not is_afa_running():
103+
return True
104+
if sys.platform != "win32":
105+
return False
106+
try:
107+
# taskkill /F /IM 精确按进程名杀;/T 连带子进程。
108+
subprocess.run(
109+
["taskkill", "/F", "/T", "/IM", _AFA_PROCESS],
110+
capture_output=True,
111+
timeout=10,
112+
)
113+
except (OSError, subprocess.SubprocessError):
114+
logger.exception("停止 AFA 失败")
115+
return False
116+
117+
import time
118+
119+
deadline = time.monotonic() + timeout
120+
while time.monotonic() < deadline:
121+
if not is_afa_running():
122+
logger.info("AFA 已停止(为自更新让路)")
123+
return True
124+
time.sleep(0.2)
125+
logger.warning("停止 AFA 超时(%ss 仍存活),更新可能跳过 afa/AFA.exe", timeout)
126+
return False

aao/resources/updater.py

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,15 @@ def download_update(
203203
if progress_cb:
204204
progress_cb(0, total)
205205

206-
opener = _make_opener(_settings_proxy())
206+
proxy = _settings_proxy()
207+
logger.info(
208+
"开始下载更新包: %s -> %s (公告大小 %d bytes, 代理 %s)",
209+
info.asset.url,
210+
dest,
211+
total,
212+
"开" if proxy else "关",
213+
)
214+
opener = _make_opener(proxy)
207215
token = _settings_github_token()
208216
for attempt in range(1, _DOWNLOAD_RETRIES + 1):
209217
try:
@@ -213,16 +221,21 @@ def download_update(
213221
# 回退用 info.asset.size。
214222
cl = resp.headers.get("Content-Length")
215223
total = int(cl) if cl and cl.isdigit() else total
224+
logger.info("下载连接已建立 (Content-Length=%s, 实际 total=%d)", cl, total)
216225
dest.parent.mkdir(parents=True, exist_ok=True)
217226
tmp = dest.with_suffix(dest.suffix + ".tmp")
218227
downloaded = 0
219228
last_report = 0
229+
# 进度里程碑:每 10% 落一条日志(与 UI progress_cb 的 512KB 回调独立,
230+
# 后者只更新状态栏不写文件)。total 未知时改按字节量里程碑。
231+
next_milestone_pct = 10
232+
next_milestone_bytes = 10 * 1024 * 1024 # 10MB
220233
with tmp.open("wb") as f:
221234
while True:
222235
if cancel is not None and cancel.is_set():
223236
f.close()
224237
tmp.unlink(missing_ok=True)
225-
logger.info("更新下载已取消")
238+
logger.info("更新下载已取消 (已下 %d bytes)", downloaded)
226239
return False
227240
chunk = resp.read(_CHUNK_SIZE)
228241
if not chunk:
@@ -232,12 +245,24 @@ def download_update(
232245
if progress_cb and downloaded - last_report >= _PROGRESS_REPORT_BYTES:
233246
progress_cb(downloaded, total)
234247
last_report = downloaded
248+
# 里程碑日志
249+
if total > 0:
250+
pct = downloaded * 100 // total
251+
if pct >= next_milestone_pct:
252+
logger.info(
253+
"下载进度: %d%% (%d/%d bytes)", pct, downloaded, total
254+
)
255+
next_milestone_pct = (pct // 10 + 1) * 10
256+
elif downloaded >= next_milestone_bytes:
257+
logger.info("下载进度: %d bytes (总长未知)", downloaded)
258+
next_milestone_bytes += 10 * 1024 * 1024
235259
tmp.replace(dest)
236260
if progress_cb:
237261
progress_cb(downloaded, total or downloaded)
238262
logger.info("更新包下载完成: %s (%d bytes)", dest.name, downloaded)
239263
return True
240264
except Exception as e: # noqa: BLE001
265+
logger.warning("更新包下载第 %d/%d 次失败: %s", attempt, _DOWNLOAD_RETRIES, e)
241266
if attempt < _DOWNLOAD_RETRIES:
242267
time.sleep(_DOWNLOAD_BACKOFF_SEC * attempt)
243268
continue
@@ -265,6 +290,15 @@ def apply_update(self, zip_path: Path) -> None:
265290
logger.warning("apply_update 仅在打包环境可用,开发环境跳过")
266291
return
267292

293+
# 先停掉自带 AFA:它是 detached 独立进程,aao.app 退出带不走它,
294+
# 若不停,robocopy 覆盖 afa/AFA.exe 会因占用静默跳过(rc<8 不报错)→ 用户拿旧 AFA。
295+
try:
296+
from aao.core.afa import stop_afa
297+
298+
stop_afa()
299+
except Exception: # noqa: BLE001
300+
logger.exception("停 AFA 失败,继续更新(afa/AFA.exe 可能被跳过)")
301+
268302
install_dir = project_root() # exe 同级目录
269303
exe_name = Path(sys.executable).name
270304
# 中转 bat 必须在安装目录之外(否则无法重命名安装目录),放 %TEMP%。
@@ -283,12 +317,38 @@ def apply_update(self, zip_path: Path) -> None:
283317
bat_path=bat_path,
284318
)
285319
bat_path.write_text(bat, encoding="gbk", errors="replace")
286-
logger.info("启动自更新中转脚本: %s", bat_path)
287320

288-
# /B 不开新控制台窗口;DETACHED_PROCESS 让它脱离父进程生命周期。
321+
# 中转 bat 全程无控制台(DETACHED + CREATE_NO_WINDOW),其 echo/命令输出原本无处可去。
322+
# 用 Popen 的 stdout/stderr 文件句柄重定向到 self_update.log,解压/robocopy/重启
323+
# 任一步失败都会留痕(bat 失败后仍会自删,但 log 保留)。debug/ 已被 bat 的 robocopy
324+
# 排除,不会被覆盖。
325+
#
326+
# ⚠️ 不要把重定向拼进 cmd 命令行(如 ["cmd","/c",f'"{bat}" >> "{log}" 2>&1']):
327+
# 路径含空格时引号嵌套会让 cmd 解析失败,bat 根本不执行
328+
# (实测:log 不建、bat/zip 残留在 TEMP)。
329+
# 用文件句柄重定向则完全绕开命令行引号问题。
330+
log_path = install_abs / "debug" / "aao" / "self_update.log"
331+
log_path.parent.mkdir(parents=True, exist_ok=True)
332+
logger.info("启动自更新中转脚本: %s (日志 -> %s)", bat_path, log_path)
333+
334+
# 覆盖写本次日志("w"),旧更新日志不累积。句柄交给子进程,本进程退出不关闭。
335+
log_fp = log_path.open("w", encoding="utf-8", errors="replace")
336+
337+
# 让中转 bat 脱离本进程生命周期:app 退出后仍能跑完替换+重启。
338+
# - DETACHED_PROCESS:子进程不继承父控制台(无黑窗)
339+
# - CREATE_NEW_PROCESS_GROUP:独立进程组,不受父 Ctrl 信号影响
340+
# - CREATE_BREAKAWAY_FROM_JOB (0x01000000):脱离父进程所在 Job。
341+
# 关键:PyInstaller exe 退出时,Windows Job 的 kill-on-close 会连带终止所有
342+
# 子进程——不加此 flag,bat 会在 app.quit() 后被一起杀掉(实测:log 停在
343+
# "waiting for app to exit",exe 已退出但 bat 也死了,解压/重启从未执行)。
344+
# DETACHED_PROCESS 与 CREATE_NO_WINDOW (0x08000000) 互斥,前者已隐含无控制台。
289345
subprocess.Popen(
290346
["cmd", "/c", str(bat_path)],
291-
creationflags=subprocess.DETACHED_PROCESS | 0x08000000, # CREATE_NO_WINDOW
347+
stdout=log_fp,
348+
stderr=subprocess.STDOUT,
349+
creationflags=subprocess.DETACHED_PROCESS
350+
| subprocess.CREATE_NEW_PROCESS_GROUP
351+
| 0x01000000,
292352
close_fds=True,
293353
)
294354

@@ -397,6 +457,19 @@ def _build_updater_bat(
397457
'exit 0 } catch { exit 1 }"'
398458
)
399459

460+
# 等待 exe 退出:用 PowerShell 单行检测,绝不用 cmd 管道(tasklist | findstr/find)。
461+
# 原因:bat 以 DETACHED_PROCESS 启动(无控制台),管道里的 findstr/find 作为控制台子进程
462+
# 会被 Windows 分配新控制台 → 弹黑窗,且管道不关闭时 findstr 卡死;for/l 循环每秒再 spawn
463+
# 一个新的 → 用户看到"findstr 窗关了又弹新的"。Get-Process 不走 cmd 管道,无子进程弹窗。
464+
# exit code 区分:0=exe 已退出,1=超时仍存活。.format 里 PowerShell 的 { } 写成 {{ }}。
465+
exe_base = exe[:-4] if exe.lower().endswith(".exe") else exe
466+
ps_wait = (
467+
"powershell -NoProfile -Command \"$ErrorActionPreference='SilentlyContinue';"
468+
f" $t=0; while ((Get-Process -Name '{exe_base}') -and ($t -lt {_WAIT_EXIT_TIMEOUT}))"
469+
" { Start-Sleep -Milliseconds 800; $t++ };"
470+
f" if (Get-Process -Name '{exe_base}') {{ exit 1 }} else {{ exit 0 }}\""
471+
)
472+
400473
# bat 里用 %~dp0 取 bat 自身所在目录不可靠(我们在 %TEMP%),全部用绝对路径。
401474
return f"""@echo off
402475
chcp 65001 >nul
@@ -408,17 +481,24 @@ def _build_updater_bat(
408481
set "STAGING={staging}"
409482
set "BAT={bat_path}"
410483
484+
echo ===== aao self-update %date% %time% =====
485+
echo EXE=%EXE%
486+
echo INSTALL=%INSTALL%
487+
echo ZIP=%ZIP%
411488
echo [aao-self-update] waiting for app to exit...
412-
for /l %%i in (1,1,{_WAIT_EXIT_TIMEOUT}) do (
413-
tasklist /fi "imagename eq %EXE%" 2>nul | findstr /i "%EXE%" >nul
414-
if errorlevel 1 goto :exited
415-
timeout /t 1 /nobreak >nul
489+
REM PowerShell 检测 exe 退出(exit 0=已退出, 1=超时);不用 cmd 管道避免 findstr 弹窗。
490+
{ps_wait}
491+
if errorlevel 1 (
492+
echo [aao-self-update] WARN: app still running after {_WAIT_EXIT_TIMEOUT}s, aborting.
493+
goto :cleanup_self
416494
)
417-
echo [aao-self-update] WARN: app still running after {_WAIT_EXIT_TIMEOUT}s, aborting.
418-
goto :cleanup_self
419495
420496
:exited
421-
echo [aao-self-update] app exited. extracting...
497+
echo [aao-self-update] app exited.
498+
REM 兜底停 AFA(Python 端已尝试过,此处防用户自开的实例占用 afa/AFA.exe)。
499+
REM 用 taskkill /IM 精确按名杀,2>nul 吞掉“无此进程”的输出。
500+
taskkill /F /T /IM AFA.exe >nul 2>&1
501+
echo [aao-self-update] extracting...
422502
if exist "%STAGING%" rmdir /s /q "%STAGING%"
423503
424504
REM 重试解压

aao/ui/settings_page.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,13 @@ def _on_update_found(self, info: ReleaseInfo) -> None:
673673
打包后的 aao.app/,对源码树会破坏 .git/aao 等目录。故源码模式只提示
674674
用 git pull 更新,不进入下载流程。
675675
"""
676+
# check_update 的 worker 已 emit 完结果,线程无事可做——主动结束并清理。
677+
# 它只 emit 了 update_found(没走 finished_ok/failed),QThread 不会自动退出,
678+
# self._thread 也不会被 _cleanup_thread 清空。若不在此清理,弹窗里点「立即更新」
679+
# 触发的 _run_resource("download") 会被守卫挡住(“上一次操作还在进行中…”),
680+
# 下载线程根本起不来。模态弹窗会阻塞主线程事件循环,故必须在弹窗前同步清理。
681+
self._finalize_current_resource()
682+
676683
from aao.utils.runtime_paths import is_frozen
677684

678685
self._pending_update = info
@@ -751,12 +758,23 @@ def _on_download_ready(self, zip_path: Path) -> None:
751758
self.lbl_op.setText(f"启动更新失败: {e}")
752759
return
753760
logger.info("自更新中转已启动,退出主进程以完成安装")
754-
# 触发真退出(绕过最小化到托盘的 close 偏好)
761+
# 触发真退出:必须走 MainWindow 的强制退出路径(设 _force_quit + 停 worker + quit),
762+
# 不能只调 QApplication.quit()——后者不设 _force_quit,closeEvent 会弹"关闭行为选择"
763+
# 对话框,且 minimize 偏好下 exe 不真退出,bat 的等退出循环永远等不到,更新卡死。
755764
from PySide6.QtWidgets import QApplication
756765

757766
app = QApplication.instance()
758-
if app is not None:
759-
app.quit()
767+
if app is None:
768+
return
769+
# topLevelWidgets 是 QApplication 的静态方法;app 实例类型标注为 QCoreApplication,
770+
# 故通过类名调用,绕过类型窄化。
771+
for w in QApplication.topLevelWidgets():
772+
quit_fn = getattr(w, "_quit_from_tray", None)
773+
if callable(quit_fn):
774+
quit_fn()
775+
return
776+
# 兜底(找不到 MainWindow):直接 quit
777+
app.quit()
760778

761779
def _on_res_done(self, msg: str) -> None:
762780
self.lbl_op.setText(msg)
@@ -774,3 +792,23 @@ def _cleanup_thread(self) -> None:
774792
self._worker = None
775793
self._thread = None
776794
self._res_mode = None
795+
796+
def _finalize_current_resource(self) -> None:
797+
"""主动结束并清理当前资源 worker 线程。
798+
799+
用于 check_update 发现更新后:worker 只 emit 了 update_found,QThread 不会
800+
自动退出,self._thread 不会被 _cleanup_thread 清空,导致后续下载被
801+
_run_resource 的“上一次操作还在进行中”守卫挡住。此处同步 quit+wait 清理,
802+
并恢复按钮状态(update_found 不走 _on_res_done,按钮不会自行恢复)。
803+
804+
_cleanup_thread 仍会由 thread.finished 触发一次,但此时 self._thread 已为 None,
805+
其逻辑幂等,不会重复出错。
806+
"""
807+
if self._thread is not None:
808+
self._thread.quit()
809+
self._thread.wait()
810+
self._worker = None
811+
self._thread = None
812+
self._res_mode = None
813+
self._set_res_buttons(True)
814+
self.btn_sync.setText("🔄 同步资源")

0 commit comments

Comments
 (0)