http_downloader.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. #!/usr/bin/env python3
  2. """
  3. 996传奇客户端 httpDownloader 接口还原 + 等价 Python 实现
  4. 来源: 二进制 httpDownloader:AsyncDownloadEx 等 17 个 tolua 方法
  5. + 公开 curl_easy_setopt 文档对照
  6. 职责: libcurl 异步下载封装, 用于热更/资源下载/补丁包
  7. 典型场景:
  8. 1. 启动期: 下载 manifest.txt / project.manifest, 对比本地版本
  9. 2. 增量期: 下载 .zip 补丁包 (签名校验)
  10. 3. 资源期: 下载 .png/.plist/.map 等 (单文件)
  11. 与原二进制差异 (Python 等价实现, 非直接对应):
  12. - 异步 → 同步 + threading (Python 3.13 之前原生 asyncio 不普及)
  13. - 进度回调: callback(downloaded, total) → 用闭包实现
  14. - 限速: 模拟 (token bucket)
  15. """
  16. from __future__ import annotations
  17. import os
  18. import sys
  19. import time
  20. import hashlib
  21. import threading
  22. import urllib.request
  23. import urllib.error
  24. from typing import Optional, Callable
  25. from dataclasses import dataclass, field
  26. # ============== 二进制接口契约 (17 个 tolua 方法) ==============
  27. # 单例
  28. # httpDownloader:Inst() -> httpDownloader*
  29. # (Python 等价: 模块级全局 state)
  30. # 配置 (setter)
  31. # SetDownloadURL(url) - 基础 URL
  32. # SetDownloadDir(dir) - 保存目录
  33. # SetTimeOut(sec) - 超时
  34. # SetSpeedLimit(bytes_per_sec) - 限速
  35. # SetResourceVer(ver) - 资源版本号
  36. # EnableDownload(flag) - 启用/禁用开关
  37. # SetDownloadProgress(cb) - 进度回调 Lua 函数
  38. # 状态 (getter)
  39. # GetLastCurlCode() - 最近一次 curl 错误码
  40. # GetLastHttpCode() - 最近一次 HTTP 状态码
  41. # IsLastDownloadOK() - 是否成功
  42. # IsEnableDownaload() - 启用状态 (typo 是原二进制写法)
  43. # 操作
  44. # AsyncDownloadEasy(url) - 简单下载 (默认设置)
  45. # AsyncDownloadEx(url, save_as) - 完整下载 (指定保存名)
  46. # CleanupAllDownload() - 取消所有下载
  47. # Destroy() - 销毁单例
  48. # ============== 数据结构 ==============
  49. @dataclass
  50. class DownloadTask:
  51. url: str
  52. save_as: str # 相对 SetDownloadDir 的路径
  53. total_bytes: int = 0
  54. downloaded: int = 0
  55. started_at: float = 0.0
  56. finished_at: float = 0.0
  57. http_code: int = 0
  58. error: Optional[str] = None
  59. sha1: str = "" # 期望 sha1 (manifest 提供)
  60. @dataclass
  61. class DownloadState:
  62. """httpDownloader 全局状态 (Python 等价)"""
  63. base_url: str = ""
  64. download_dir: str = ""
  65. timeout: float = 30.0
  66. speed_limit_bps: int = 0 # 0 = 无限制
  67. resource_ver: str = ""
  68. enabled: bool = True
  69. last_http_code: int = 0
  70. last_curl_code: int = 0
  71. last_ok: bool = False
  72. progress_cb: Optional[Callable] = None
  73. _tasks: dict = field(default_factory=dict)
  74. _lock: threading.Lock = field(default_factory=threading.Lock)
  75. # ============== 单例 ==============
  76. _STATE: Optional[DownloadState] = None
  77. def inst() -> DownloadState:
  78. """httpDownloader:Inst() 等价"""
  79. global _STATE
  80. if _STATE is None:
  81. _STATE = DownloadState()
  82. return _STATE
  83. def destroy() -> None:
  84. """httpDownloader:Destroy() 等价"""
  85. global _STATE
  86. _STATE = None
  87. # ============== Setter ==============
  88. def set_download_url(url: str) -> None: inst().base_url = url
  89. def set_download_dir(d: str) -> None: inst().download_dir = d
  90. def set_timeout(sec: float) -> None: inst().timeout = sec
  91. def set_speed_limit(bps: int) -> None: inst().speed_limit_bps = bps
  92. def set_resource_ver(ver: str) -> None: inst().resource_ver = ver
  93. def enable_download(flag: bool) -> None: inst().enabled = flag
  94. def set_download_progress(cb: Callable) -> None: inst().progress_cb = cb
  95. # ============== Getter ==============
  96. def get_last_http_code() -> int: return inst().last_http_code
  97. def get_last_curl_code() -> int: return inst().last_curl_code
  98. def is_last_download_ok() -> bool: return inst().last_ok
  99. def is_enable_download() -> bool: return inst().enabled
  100. # ============== 核心: 同步下载 (AsyncDownloadEx 的同步等价) ==============
  101. def _check_disk_space(save_path: str, expected_size: int) -> None:
  102. """下载前检查磁盘空间"""
  103. need_bytes = expected_size + 4096
  104. free = os.statvfs(os.path.dirname(save_path) or ".").f_bavail * os.statvfs(
  105. os.path.dirname(save_path) or ".").f_frsize
  106. if free < need_bytes:
  107. raise IOError(f"磁盘空间不足: need={need_bytes} free={free}")
  108. def _throttle(downloaded: int, started_at: float, bps: int) -> None:
  109. """限速: 令牌桶简化版, 让已下载量/已用时间 >= bps/1"""
  110. if bps <= 0:
  111. return
  112. elapsed = time.time() - started_at
  113. expected_min = downloaded / bps
  114. if expected_min > elapsed:
  115. time.sleep(expected_min - elapsed)
  116. def _download_blocking(task: DownloadTask, state: DownloadState) -> bool:
  117. """单次同步下载 (AsyncDownloadEx 的同步等价实现)"""
  118. full_url = task.url if task.url.startswith("http") else state.base_url + task.url
  119. save_path = os.path.join(state.download_dir, task.save_as)
  120. os.makedirs(os.path.dirname(save_path), exist_ok=True)
  121. task.started_at = time.time()
  122. state.last_http_code = 0
  123. state.last_curl_code = 0
  124. state.last_ok = False
  125. try:
  126. req = urllib.request.Request(full_url)
  127. with urllib.request.urlopen(req, timeout=state.timeout) as resp:
  128. task.http_code = resp.getcode()
  129. state.last_http_code = task.http_code
  130. if task.http_code != 200:
  131. task.error = f"HTTP {task.http_code}"
  132. return False
  133. total_hdr = resp.headers.get("Content-Length")
  134. if total_hdr:
  135. task.total_bytes = int(total_hdr)
  136. _check_disk_space(save_path, task.total_bytes)
  137. with open(save_path, "wb") as f:
  138. while True:
  139. chunk = resp.read(8192)
  140. if not chunk:
  141. break
  142. f.write(chunk)
  143. task.downloaded += len(chunk)
  144. _throttle(task.downloaded, task.started_at, state.speed_limit_bps)
  145. if state.progress_cb:
  146. try:
  147. state.progress_cb(task.downloaded, task.total_bytes)
  148. except Exception:
  149. pass # 回调异常不阻断下载
  150. # sha1 校验
  151. if task.sha1:
  152. actual = hashlib.sha1(open(save_path, "rb").read()).hexdigest()
  153. if actual != task.sha1:
  154. task.error = f"sha1 mismatch: {actual[:8]}... != {task.sha1[:8]}..."
  155. return False
  156. task.finished_at = time.time()
  157. state.last_ok = True
  158. return True
  159. except urllib.error.HTTPError as e:
  160. task.http_code = e.code
  161. state.last_http_code = e.code
  162. task.error = f"HTTPError: {e.code} {e.reason}"
  163. return False
  164. except urllib.error.URLError as e:
  165. task.error = f"URLError: {e.reason}"
  166. return False
  167. except Exception as e:
  168. task.error = f"{type(e).__name__}: {e}"
  169. return False
  170. def async_download_ex(url: str, save_as: str, sha1: str = "") -> DownloadTask:
  171. """
  172. httpDownloader:AsyncDownloadEx(url, save_as) 的 Python 等价
  173. :param url: 完整 URL 或相对于 base_url 的相对路径
  174. :param save_as: 相对 download_dir 的保存路径
  175. :param sha1: 期望 sha1 (空=不校验)
  176. :return: DownloadTask (可查 finished_at / error / downloaded)
  177. """
  178. state = inst()
  179. task = DownloadTask(url=url, save_as=save_as, sha1=sha1)
  180. if not state.enabled:
  181. task.error = "downloader disabled"
  182. return task
  183. with state._lock:
  184. state._tasks[save_as] = task
  185. _download_blocking(task, state)
  186. return task
  187. def async_download_easy(url: str) -> DownloadTask:
  188. """AsyncDownloadEasy(url) 等价: 用 URL basename 作为 save_as"""
  189. save_as = url.split("?")[0].split("/")[-1] or "download.bin"
  190. return async_download_ex(url, save_as)
  191. def cleanup_all_download() -> None:
  192. """CleanupAllDownload 等价: 清空任务列表 (本实现是同步, 无活跃任务)"""
  193. state = inst()
  194. with state._lock:
  195. state._tasks.clear()
  196. # ============== 高级: 完整热更流程 ==============
  197. def download_manifest(manifest_url: str) -> dict:
  198. """
  199. 下载并解析 manifest 文件
  200. manifest 格式 (与 cocos2d-x AssetsManagerEx 兼容):
  201. version: 1.0.0
  202. res_version: 1.0.1
  203. file_list:
  204. - path: scene/map/0.map
  205. md5: abc123...
  206. size: 6860052
  207. :return: dict
  208. """
  209. import json
  210. state = inst()
  211. state.base_url = manifest_url.rsplit("/", 1)[0] + "/"
  212. task = async_download_ex(manifest_url, "manifest.json")
  213. if not state.last_ok:
  214. raise IOError(f"manifest download failed: {task.error}")
  215. with open(os.path.join(state.download_dir, "manifest.json")) as f:
  216. return json.load(f)
  217. def download_update_files(manifest: dict, only_changed: bool = True) -> list:
  218. """
  219. 根据 manifest 下载需要更新的文件
  220. :param manifest: download_manifest() 返回值
  221. :param only_changed: True=对比本地 sha1, 只下变更; False=强制全量
  222. :return: 成功下载的 DownloadTask 列表
  223. """
  224. tasks = []
  225. for entry in manifest.get("file_list", []):
  226. path = entry["path"]
  227. expected_sha1 = entry.get("md5", "")
  228. local_path = os.path.join(inst().download_dir, path)
  229. if only_changed and os.path.exists(local_path):
  230. actual = hashlib.sha1(open(local_path, "rb").read()).hexdigest()
  231. if actual == expected_sha1:
  232. continue # 本地一致, 跳过
  233. t = async_download_ex(path, path, expected_sha1)
  234. tasks.append(t)
  235. return tasks
  236. # ============== 单元自测 ==============
  237. if __name__ == "__main__":
  238. import sys
  239. # 设个 mock 状态演示
  240. set_download_dir("/tmp/httpdl_test")
  241. set_timeout(10)
  242. print("=== httpDownloader 接口自测 (mock) ===")
  243. print(f"Inst: {inst() is not None}")
  244. print(f"Inst 二次: {inst() is inst()} (应 True, 单例)")
  245. set_download_url("https://example.com/")
  246. print(f"Base URL: {inst().base_url}")
  247. print(f"Enabled: {is_enable_download()}")
  248. print(f"Last OK: {is_last_download_ok()} (应 False)")
  249. # 测一次真实下载 (小文件)
  250. print("\n=== 真实下载测试 (https://httpbin.org/bytes/1024) ===")
  251. set_download_dir("/tmp/httpdl_test")
  252. t = async_download_ex("https://httpbin.org/bytes/1024", "test.bin")
  253. print(f" task.url = {t.url}")
  254. print(f" task.save_as = {t.save_as}")
  255. print(f" task.downloaded = {t.downloaded}")
  256. print(f" task.total_bytes = {t.total_bytes}")
  257. print(f" task.http_code = {t.http_code}")
  258. print(f" task.error = {t.error}")
  259. print(f" is_last_download_ok = {is_last_download_ok()}")
  260. if t.error:
  261. print(f" [warn] 网络请求失败, 这是预期的(无网环境)")
  262. else:
  263. # 验证文件存在
  264. path = os.path.join("/tmp/httpdl_test", "test.bin")
  265. if os.path.exists(path):
  266. print(f" saved {os.path.getsize(path)}B to {path}")