Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 59 additions & 45 deletions src/trader/installer/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,66 +37,80 @@ 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
"""
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:
Expand Down
89 changes: 65 additions & 24 deletions src/trader/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 区"""
Expand Down
6 changes: 4 additions & 2 deletions src/trader/selfupdate/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
103 changes: 103 additions & 0 deletions tests/installer/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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" # 续传拼接,未从头覆盖
Loading