diff --git a/src/trader/installer/download.py b/src/trader/installer/download.py index 5b83a12..d13e663 100644 --- a/src/trader/installer/download.py +++ b/src/trader/installer/download.py @@ -37,15 +37,25 @@ async def download_with_progress( dest: Path | str, on_progress: Optional[Callable[[int, int], None]] = None, chunk_size: int = 65536, + max_retries: int = 4, ) -> Path: """ - 异步下载文件,支持 Range 续传和进度回调。 + 异步下载文件:单次 GET + 自动重试 + 断点续传 + 进度回调。 + + 针对慢/不稳的链路(如国内访问 GitHub Release CDN)优化: + - **单 GET**:一次请求既拿大小又下数据(旧实现会先发一个 GET 只读 Content-Length + 再发第二个 GET 真正下载,在慢链路上等于翻倍建连与失败面)。 + - **自动重试 + 续传**:单次尝试失败时**保留已下的半成品**,下次尝试带 `Range` 头接着下, + 指数退避;只有重试全部耗尽才向上抛异常。失败时不删半成品,好让上层"重试"按钮也能续传。 + - **慢链路友好超时**:不设总时长上限(大文件慢下也不该被总时长砍掉),只在"连不上" + (connect)和"卡住不再有数据"(sock_read)时超时失败。 Args: url: 下载 URL dest: 目标路径 on_progress: 进度回调,接收 (bytes_downloaded, total_bytes) chunk_size: 单次读取大小 + max_retries: 最大尝试次数(含首次) Returns: 目标文件 Path @@ -53,50 +63,54 @@ async def download_with_progress( dest_path = Path(dest) dest_path.parent.mkdir(parents=True, exist_ok=True) - # 检查断点续传条件 - resume_from = 0 - if dest_path.exists(): - resume_from = dest_path.stat().st_size - logger.info("断点续传:从 %d 字节继续", resume_from) - - try: - async with aiohttp.ClientSession() as session: - headers = {} - if resume_from > 0: - headers["Range"] = f"bytes={resume_from}-" - - async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=3600)) as resp: - # 检查 Content-Length - total_size = int(resp.headers.get("Content-Length", 0)) - if resume_from > 0 and resp.status == 206: # Partial Content - # Range 续传时,加上已下载的大小 - total_size = resume_from + total_size - elif resp.status == 200: - resume_from = 0 # 服务器不支持 Range,重新开始 - dest_path.unlink(missing_ok=True) - else: - raise Exception(f"HTTP {resp.status}") - - logger.info("开始下载:url=%s total=%d bytes", url, total_size) - - bytes_downloaded = resume_from - async with aiohttp.ClientSession() as session2: - async with session2.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=3600)) as resp2: - mode = "ab" if resume_from > 0 else "wb" - with open(dest_path, mode) as f: - async for chunk in resp2.content.iter_chunked(chunk_size): - f.write(chunk) - bytes_downloaded += len(chunk) - if on_progress: - on_progress(bytes_downloaded, total_size) - - logger.info("下载完成:%s(%d bytes)", dest_path, bytes_downloaded) - return dest_path - - except Exception as e: - logger.error("下载失败:%s", e) - dest_path.unlink(missing_ok=True) - raise + # 连不上快速失败;下载中只要还有数据就耐心等,不给总时长封顶。 + timeout = aiohttp.ClientTimeout(total=None, connect=30, sock_connect=30, sock_read=60) + + last_err: Optional[Exception] = None + for attempt in range(1, max_retries + 1): + resume_from = dest_path.stat().st_size if dest_path.exists() else 0 + headers = {"Range": f"bytes={resume_from}-"} if resume_from > 0 else {} + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=timeout) as resp: + content_len = int(resp.headers.get("Content-Length", 0)) + if resp.status == 206: # Partial Content:服务器接受续传 + total_size = resume_from + content_len + mode = "ab" + elif resp.status == 200: # 全新下载或服务器不支持 Range + resume_from = 0 + total_size = content_len + mode = "wb" + else: + raise Exception(f"HTTP {resp.status}") + + logger.info( + "下载(第%d/%d次) url=%s resume=%d total=%d", + attempt, max_retries, url, resume_from, total_size, + ) + bytes_downloaded = resume_from + with open(dest_path, mode) as f: + async for chunk in resp.content.iter_chunked(chunk_size): + f.write(chunk) + bytes_downloaded += len(chunk) + if on_progress: + on_progress(bytes_downloaded, total_size) + + logger.info("下载完成:%s(%d bytes)", dest_path, bytes_downloaded) + return dest_path + + except Exception as e: + last_err = e + have = dest_path.stat().st_size if dest_path.exists() else 0 + logger.warning( + "下载第 %d/%d 次失败:%s(保留已下 %d 字节,重试将续传)", + attempt, max_retries, e, have, + ) + if attempt < max_retries: + await asyncio.sleep(min(2 ** attempt, 10)) # 指数退避,封顶 10s + + logger.error("下载最终失败(%d 次尝试后):%s", max_retries, last_err) + raise last_err if last_err else Exception("下载失败") async def verify_sha256(file_path: Path, expected_sha256: str) -> bool: diff --git a/src/trader/main_window.py b/src/trader/main_window.py index 8f30bf2..907cd85 100644 --- a/src/trader/main_window.py +++ b/src/trader/main_window.py @@ -13,6 +13,7 @@ import threading import time import tkinter as tk +import webbrowser from dataclasses import dataclass, field from tkinter import scrolledtext, ttk from typing import Callable, Optional @@ -208,22 +209,32 @@ def _build_ui(self) -> None: ) # 进度条只在 downloading 状态才 pack 出来,见 _sync_state + # 三个操作按钮都在 _build_ui 阶段创建、但**不在这里 pack**——它们的显隐完全由 + # _render_self_update_banner 按状态统一控制(下载中一个都不显示,只留进度条;避免 + # Windows 上 disabled 按钮看着仍可点的歧义)。 self.self_update_skip_btn = tk.Button( self.self_update_box, text="跳过", command=self._on_click_self_update_skip, relief="flat", bg="#ffffff", fg="#57606a", font=("Helvetica", 8), padx=8, pady=2, cursor="hand2", bd=0, highlightbackground="#d0d7de", highlightthickness=1 ) - self.self_update_skip_btn.pack(side="right", padx=(0, 4), pady=6) + + # 「手动下载」:错误态兜底,用系统浏览器打开新版 exe 直链 + self.self_update_manual_btn = tk.Button( + self.self_update_box, text="手动下载", command=self._on_click_self_update_manual, + relief="flat", bg="#ffffff", fg="#0969da", font=("Helvetica", 8), + padx=8, pady=2, cursor="hand2", bd=0, + highlightbackground="#d0d7de", highlightthickness=1 + ) self.self_update_btn = tk.Button( self.self_update_box, text="立即更新", command=self._on_click_self_update, relief="flat", bg="#e0b656", fg="#ffffff", font=("Helvetica", 8, "bold"), padx=8, pady=2, cursor="hand2", bd=0 ) - self.self_update_btn.pack(side="right", padx=10, pady=6) # self_update_box 本身默认不 pack(不加入 main_pane 的显示),子控件的 pack # 状态不受影响——_sync_state 检测到新版本时才把 self_update_box 本身 pack 出来 + self._self_update_rendered = None # 记录上次渲染的 (status, version),避免每帧重排闪烁 # ========================================== # 【左分栏】MCP 网关连接与监控舱 (固定宽 300px) @@ -508,6 +519,50 @@ def _on_click_self_update(self) -> None: def _on_click_self_update_skip(self) -> None: self.state.update(self_update_info=None) + def _on_click_self_update_manual(self) -> None: + """错误态「手动下载」:用系统浏览器打开新版 exe 直链。 + + 程序内下载在慢/不稳的链路上可能反复失败,浏览器或下载工具往往更快更稳; + 点了不至于卡在"失败了不知道怎么办"。 + """ + info = self.state.snapshot().get("self_update_info") + url = getattr(info, "exe_url", None) or \ + "https://github.com/Guling-Pro/guling-trader/releases/latest" + try: + webbrowser.open(url) + self.state.log(f"已在浏览器打开下载链接:{url}") + except Exception as e: + self.state.log(f"⚠ 打开浏览器失败,请手动访问 GitHub Releases:{e}") + + def _render_self_update_banner(self, status: str, info) -> None: + """按状态统一摆放横幅内的进度条/按钮:先全部收起,再摆出该状态需要的控件。 + + 这样做不残留、不串台,且**下载中一个按钮都不显示**(只留进度条),彻底避免 + Windows 上 disabled 按钮视觉变化太弱、看着仍可点的歧义。 + """ + for w in (self.self_update_progress_bar, self.self_update_btn, + self.self_update_manual_btn, self.self_update_skip_btn): + if w.winfo_ismapped(): + w.pack_forget() + + if status == "downloading": + # 下载中:只显示进度条,无任何按钮 + self.self_update_label.config(text=f"正在更新到 v{info.latest_version}…") + self.self_update_progress_bar.pack(side="left", padx=10, pady=6) + elif status == "error": + self.self_update_label.config(text="更新失败 —— 可重试,或点「手动下载」用浏览器下载") + self.self_update_skip_btn.pack(side="right", padx=(0, 4), pady=6) + self.self_update_manual_btn.pack(side="right", padx=(0, 4), pady=6) + self.self_update_btn.config(state="normal", text="重试更新") + self.self_update_btn.pack(side="right", padx=10, pady=6) + else: # idle:发现新版本 + self.self_update_label.config( + text=f"发现新版本 v{info.latest_version}(当前 v{info.current_version})" + ) + self.self_update_skip_btn.pack(side="right", padx=(0, 4), pady=6) + self.self_update_btn.config(state="normal", text="立即更新") + self.self_update_btn.pack(side="right", padx=10, pady=6) + def _toggle_ths_plugin(self) -> None: """开启/折叠同花顺交易插件""" self.enable_ths_plugin = not self.enable_ths_plugin @@ -658,36 +713,22 @@ def _sync_state(self) -> None: if update_info is None: if self.self_update_box.winfo_ismapped(): self.self_update_box.pack_forget() + self._self_update_rendered = None else: if not self.self_update_box.winfo_ismapped(): self.self_update_box.pack(fill="x", padx=12, pady=(12, 0), before=self.left_frame) + # 进度条数值每帧刷新(便宜,不触发重排) if update_status == "downloading": done, total = snap.get("self_update_progress") or (0, 0) pct = (done / total * 100) if total > 0 else 0 - self.self_update_label.config(text=f"正在更新到 v{update_info.latest_version}...") self.self_update_progress_var.set(pct) - if not self.self_update_progress_bar.winfo_ismapped(): - self.self_update_progress_bar.pack(side="left", padx=10, pady=6) - self.self_update_btn.config(state="disabled") - if self.self_update_skip_btn.winfo_ismapped(): - self.self_update_skip_btn.pack_forget() - elif update_status == "error": - self.self_update_label.config(text="更新失败,可重试或前往 GitHub Releases 手动下载") - if self.self_update_progress_bar.winfo_ismapped(): - self.self_update_progress_bar.pack_forget() - self.self_update_btn.config(state="normal", text="重试更新") - if not self.self_update_skip_btn.winfo_ismapped(): - self.self_update_skip_btn.pack(side="right", padx=(0, 4), pady=6) - else: - self.self_update_label.config( - text=f"发现新版本 v{update_info.latest_version}(当前 v{update_info.current_version})" - ) - if self.self_update_progress_bar.winfo_ismapped(): - self.self_update_progress_bar.pack_forget() - self.self_update_btn.config(state="normal", text="立即更新") - if not self.self_update_skip_btn.winfo_ismapped(): - self.self_update_skip_btn.pack(side="right", padx=(0, 4), pady=6) + + # 控件显隐仅在 状态/版本 变化时重排一次,避免每 100ms 反复 pack 造成闪烁 + render_key = (update_status, update_info.latest_version) + if self._self_update_rendered != render_key: + self._self_update_rendered = render_key + self._render_self_update_banner(update_status, update_info) def _drain_log_queue(self) -> None: """把 SharedState.log_messages 队列里的内容刷到 log_text 区""" diff --git a/src/trader/selfupdate/apply.py b/src/trader/selfupdate/apply.py index 4c032ed..b7c643c 100644 --- a/src/trader/selfupdate/apply.py +++ b/src/trader/selfupdate/apply.py @@ -89,15 +89,17 @@ async def run_update( expected_sha256 = _parse_sha256_file(sha_text) if not await verify_sha256(new_path, expected_sha256): + # 校验不过 = 下到的字节不可信,删掉半成品,下次从头下载 + new_path.unlink(missing_ok=True) raise SelfUpdateError("下载文件 SHA256 校验不匹配,可能下载不完整或被篡改") _swap_files(exe_path, new_path, old_path) except SelfUpdateError: - new_path.unlink(missing_ok=True) raise except Exception as e: - new_path.unlink(missing_ok=True) + # 下载/网络类失败:保留 .new 半成品,用户点"重试更新"时可断点续传,不从 0 重来。 + # (下次启动时 cleanup_orphan_files 会兜底清掉遗留的 .new,不会长期堆积。) raise SelfUpdateError(f"下载或替换过程出错:{e}") from e release_singleton_mutex() diff --git a/tests/installer/test_download.py b/tests/installer/test_download.py index b62499e..217537d 100644 --- a/tests/installer/test_download.py +++ b/tests/installer/test_download.py @@ -194,3 +194,106 @@ async def test_verify_sha256_mismatch(): ) assert result is False + + +@pytest.mark.asyncio +async def test_download_retries_then_succeeds(tmp_path, monkeypatch): + """首次尝试网络失败 → 自动重试 → 第二次成功(验证重试逻辑,不再一抖动就整体失败)""" + from trader.installer import download + + dest = tmp_path / "f.exe" + progress = [] + + class GoodResp: + status = 200 + headers = {"Content-Length": "4"} + + def __init__(self): + self.content = self + + async def iter_chunked(self, size): + yield b"abcd" + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + class FailSession: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + def get(self, *a, **kw): + raise ConnectionError("boom") + + class GoodSession: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + def get(self, *a, **kw): + return GoodResp() + + sessions = iter([FailSession(), GoodSession()]) + monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: next(sessions)) + monkeypatch.setattr(download.asyncio, "sleep", AsyncMock()) # 别真等退避 + + result = await download.download_with_progress( + "https://example.com/f.exe", dest, on_progress=lambda d, t: progress.append((d, t)) + ) + + assert result == dest + assert dest.read_bytes() == b"abcd" + assert progress[-1] == (4, 4) + + +@pytest.mark.asyncio +async def test_download_resumes_with_range_header(tmp_path, monkeypatch): + """已存在半成品 → 带 Range 头续传 → 拼接完整(验证断点续传,不从 0 重来)""" + from trader.installer import download + + dest = tmp_path / "f.exe" + dest.write_bytes(b"AB") # 上次已下 2 字节 + + captured = {} + + class Resp206: + status = 206 + headers = {"Content-Length": "2"} # 剩余 2 字节 + + def __init__(self): + self.content = self + + async def iter_chunked(self, size): + yield b"CD" + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + class Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + def get(self, url, headers=None, timeout=None): + captured["headers"] = headers or {} + return Resp206() + + monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: Session()) + monkeypatch.setattr(download.asyncio, "sleep", AsyncMock()) + + result = await download.download_with_progress("https://example.com/f.exe", dest) + + assert captured["headers"].get("Range") == "bytes=2-" + assert dest.read_bytes() == b"ABCD" # 续传拼接,未从头覆盖