@@ -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
402475chcp 65001 >nul
@@ -408,17 +481,24 @@ def _build_updater_bat(
408481set "STAGING={ staging } "
409482set "BAT={ bat_path } "
410483
484+ echo ===== aao self-update %date% %time% =====
485+ echo EXE=%EXE%
486+ echo INSTALL=%INSTALL%
487+ echo ZIP=%ZIP%
411488echo [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...
422502if exist "%STAGING%" rmdir /s /q "%STAGING%"
423503
424504REM 重试解压
0 commit comments