docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
commit
85e9a53750
65 files changed
+18603
No files matched your search
@@ -0,0 +1,323 @@
|
||||
"""地图瓦片本地缓存代理。
|
||||
|
||||
- 前端只请求 /api/tiles/{source}/{z}/{x}/{y}.png,由本模块回源到瓦片服务商并落盘缓存。
|
||||
- 缓存目录可配置(默认 ~/.ios-location-tiles),加载过的区域在断网时仍可显示。
|
||||
- 无第三方依赖:urllib 回源 + 严格锁保证并发安全。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import protocol
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||
)
|
||||
_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
# 1x1 透明 PNG,回源彻底失败时返回,避免前端整块报错
|
||||
PLACEHOLDER_PNG = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d4944415478da63fcffff3f0300050001a5f645ea0000000049454e44ae426082"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TileResult:
|
||||
body: bytes
|
||||
content_type: str
|
||||
status: int
|
||||
source: str # "cache" | "network" | "placeholder" | "error"
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == 200
|
||||
|
||||
|
||||
class TileCache:
|
||||
def __init__(self, cache_dir: str | os.PathLike[str], max_mb: int = 1024, timeout: float = 12.0) -> None:
|
||||
self.dir = Path(cache_dir).expanduser()
|
||||
self.max_bytes = max(64, int(max_mb)) * 1024 * 1024
|
||||
self.timeout = timeout
|
||||
self._lock = threading.RLock()
|
||||
self._last_sweep = 0.0
|
||||
self._inflight: dict[str, threading.Lock] = {}
|
||||
self.stats = {"hit": 0, "miss": 0, "network": 0, "failed": 0, "placeholder": 0}
|
||||
try:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ---------------- 内部 ----------------
|
||||
def _path_for(self, source: str, z: int, x: int, y: int, ext: str = ".png") -> Path:
|
||||
return self.dir / source / str(z) / str(x) / f"{y}{ext}"
|
||||
|
||||
def _find_cached(self, source: str, z: int, x: int, y: int) -> Path | None:
|
||||
for ext in (".png", ".jpg", ".webp"):
|
||||
candidate = self._path_for(source, z, x, y, ext)
|
||||
if candidate.exists() and candidate.stat().st_size > 0:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _write(self, source: str, z: int, x: int, y: int, ext: str, body: bytes) -> Path:
|
||||
path = self._path_for(source, z, x, y, ext)
|
||||
with self._lock:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_bytes(body)
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
def _sweep(self) -> None:
|
||||
"""超过容量上限时按最后访问时间淘汰旧瓦片。"""
|
||||
if self.max_bytes <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
if now - self._last_sweep < 60:
|
||||
return
|
||||
self._last_sweep = now
|
||||
entries: list[tuple[float, int, Path]] = []
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(self.dir):
|
||||
for name in files:
|
||||
if not name.endswith((".png", ".jpg", ".webp")):
|
||||
continue
|
||||
path = Path(root) / name
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total += st.st_size
|
||||
entries.append((st.st_atime, st.st_size, path))
|
||||
if total <= self.max_bytes:
|
||||
return
|
||||
entries.sort(key=lambda item: item[0])
|
||||
target = total - int(self.max_bytes * 0.85)
|
||||
freed = 0
|
||||
for _atime, size, path in entries:
|
||||
if freed >= target:
|
||||
break
|
||||
try:
|
||||
path.unlink()
|
||||
freed += size
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def _lock_for(self, key: str) -> threading.Lock:
|
||||
with self._lock:
|
||||
lock = self._inflight.get(key)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._inflight[key] = lock
|
||||
return lock
|
||||
|
||||
# ---------------- 对外 ----------------
|
||||
def get_tile(self, source_key: str, source: dict[str, Any], z: int, x: int, y: int) -> TileResult:
|
||||
cached = self._find_cached(source_key, z, x, y)
|
||||
if cached is not None:
|
||||
try:
|
||||
blob = cached.read_bytes()
|
||||
except OSError:
|
||||
blob = b""
|
||||
if blob:
|
||||
if _is_blank_tile(blob):
|
||||
# 之前可能把空瓦片缓存下来了:删掉并重新回源
|
||||
try:
|
||||
cached.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
self.stats["hit"] += 1
|
||||
try:
|
||||
os.utime(cached, None)
|
||||
except OSError:
|
||||
pass
|
||||
return TileResult(blob, _mime_for(cached.suffix), 200, "cache")
|
||||
|
||||
key = f"{source_key}/{z}/{x}/{y}"
|
||||
with self._lock_for(key):
|
||||
cached = self._find_cached(source_key, z, x, y)
|
||||
if cached is not None:
|
||||
try:
|
||||
blob = cached.read_bytes()
|
||||
except OSError:
|
||||
blob = b""
|
||||
if blob and not _is_blank_tile(blob):
|
||||
self.stats["hit"] += 1
|
||||
return TileResult(blob, _mime_for(cached.suffix), 200, "cache")
|
||||
|
||||
self.stats["miss"] += 1
|
||||
url = build_url(source, z, x, y)
|
||||
if not url:
|
||||
self.stats["failed"] += 1
|
||||
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
|
||||
try:
|
||||
body, mime = self._fetch(url)
|
||||
except Exception:
|
||||
self.stats["failed"] += 1
|
||||
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
|
||||
if not body or _is_blank_tile(body):
|
||||
# 服务商返回空图(多为瓦片不存在或参数不对):不写缓存,交给前端显示占位
|
||||
self.stats["failed"] += 1
|
||||
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
|
||||
ext = _EXT_BY_MIME.get(mime, ".png")
|
||||
self._write(source_key, z, x, y, ext, body)
|
||||
self.stats["network"] += 1
|
||||
self._sweep()
|
||||
return TileResult(body, mime, 200, "network")
|
||||
|
||||
def _fetch(self, url: str) -> tuple[bytes, str]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": _UA,
|
||||
"Accept": "image/avif,image/webp,image/png,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Referer": _referer_for(url),
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
body = response.read()
|
||||
mime = (response.headers.get("Content-Type") or "image/png").split(";")[0].strip().lower()
|
||||
if mime not in _EXT_BY_MIME and not body.startswith(b"\xff\xd8") and not body.startswith(b"\x89PNG"):
|
||||
raise ValueError("返回的不是图片")
|
||||
return body, mime
|
||||
|
||||
def clear(self) -> int:
|
||||
"""清空缓存,返回删除的瓦片数。"""
|
||||
removed = 0
|
||||
with self._lock:
|
||||
for root, _dirs, files in os.walk(self.dir, topdown=False):
|
||||
for name in files:
|
||||
try:
|
||||
(Path(root) / name).unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
def size_bytes(self) -> int:
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(self.dir):
|
||||
for name in files:
|
||||
try:
|
||||
total += (Path(root) / name).stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _mime_for(suffix: str) -> str:
|
||||
return {".jpg": "image/jpeg", ".webp": "image/webp"}.get(suffix, "image/png")
|
||||
|
||||
|
||||
def _is_blank_tile(body: bytes) -> bool:
|
||||
"""识别「全透明 / 纯色」的空瓦片。
|
||||
|
||||
某些瓦片服务在参数或图层不对时会返回 200 + 256x256 的全透明 PNG(体积很小),
|
||||
这类图直接给前端只会显示白块,因此这里当作失败处理。
|
||||
"""
|
||||
if not body:
|
||||
return True
|
||||
if not body.startswith(b"\x89PNG"):
|
||||
return False # JPEG 等格式不在此判断
|
||||
if len(body) > 6000:
|
||||
return False # 体积正常的 PNG 基本都有内容
|
||||
try:
|
||||
import zlib
|
||||
|
||||
if body[24] != 8: # bit depth
|
||||
return False
|
||||
color_type = body[25]
|
||||
pos = 8
|
||||
idat = bytearray()
|
||||
while pos + 8 <= len(body):
|
||||
length = int.from_bytes(body[pos:pos + 4], "big")
|
||||
chunk_type = body[pos + 4:pos + 8]
|
||||
data = body[pos + 8:pos + 8 + length]
|
||||
if chunk_type == b"IDAT":
|
||||
idat.extend(data)
|
||||
elif chunk_type == b"IEND":
|
||||
break
|
||||
pos += 12 + length
|
||||
if not idat:
|
||||
return True
|
||||
raw = zlib.decompress(bytes(idat))
|
||||
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color_type, 1)
|
||||
bpp = channels
|
||||
stride = 256 * bpp
|
||||
# 这里只做粗略判断:全 0(透明/黑)或单一字节值的图像视为空
|
||||
first = None
|
||||
for row in range(min(8, 256)):
|
||||
start = row * (stride + 1) + 1
|
||||
chunk = raw[start:start + stride]
|
||||
if not chunk:
|
||||
break
|
||||
if first is None:
|
||||
first = bytes(chunk)
|
||||
elif bytes(chunk) != first:
|
||||
return False
|
||||
if first is None:
|
||||
return True
|
||||
if color_type in (4, 6):
|
||||
# 带 alpha 通道:全部 alpha 为 0 视为全透明
|
||||
alpha_index = bpp - 1
|
||||
return all(first[i] == 0 for i in range(alpha_index, len(first), bpp))
|
||||
return len(set(first)) <= 1
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _referer_for(url: str) -> str:
|
||||
try:
|
||||
parts = url.split("/")
|
||||
return f"{parts[0]}//{parts[2]}/"
|
||||
except Exception:
|
||||
return "https://www.amap.com/"
|
||||
|
||||
|
||||
def build_url(source: dict[str, Any], z: int, x: int, y: int) -> str:
|
||||
"""把 {z}/{x}/{y}/{s} 模板替换成真实 URL。"""
|
||||
template = str(source.get("url") or "")
|
||||
if not template:
|
||||
return ""
|
||||
subs = source.get("subdomains") or []
|
||||
if "{s}" in template:
|
||||
if subs:
|
||||
sub = subs[(x + y) % len(subs)]
|
||||
else:
|
||||
sub = source.get("subdomain_default", "1")
|
||||
template = template.replace("{s}", str(sub))
|
||||
max_zoom = int(source.get("max_zoom") or 19)
|
||||
z = min(z, max_zoom)
|
||||
try:
|
||||
return template.format(z=z, x=x, y=y, s="")
|
||||
except (KeyError, IndexError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def validate_tile_request(z: Any, x: Any, y: Any) -> tuple[int, int, int]:
|
||||
try:
|
||||
iz, ix, iy = int(z), int(x), int(y)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise protocol.AppError("瓦片坐标必须是整数", "bad_tile") from exc
|
||||
if not (0 <= iz <= 22):
|
||||
raise protocol.AppError("缩放级别超出范围", "bad_tile")
|
||||
limit = 1 << iz
|
||||
if not (0 <= ix < limit and 0 <= iy < limit):
|
||||
raise protocol.AppError("瓦片坐标超出该缩放级别范围", "bad_tile")
|
||||
return iz, ix, iy
|
||||
Reference in new issue
Block a user