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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
222 lines
8.8 KiB
Python
222 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""把打包好的 .app 制作成可分发的 DMG 安装包。
|
|
|
|
产物:dist/虚拟定位轨迹-1.0.0.dmg
|
|
内容:应用本身 + Applications 快捷方式(拖进去即安装)+ 中文安装说明
|
|
|
|
特点:
|
|
- UDZO 压缩(zlib),体积最小且兼容性好
|
|
- 固定卷名、固定图标位置(打开 DMG 后两个图标位置整齐)
|
|
- 自定义背景图(程序化生成,无需设计稿)
|
|
- 生成后自动校验挂载与内容
|
|
|
|
用法:./.venv/bin/python tools/make_dmg.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
APP_NAME = "虚拟定位轨迹"
|
|
VERSION = "1.0.0"
|
|
APP = ROOT / "dist" / f"{APP_NAME}.app"
|
|
DMG = ROOT / "dist" / f"{APP_NAME}-{VERSION}.dmg"
|
|
VOLUME = APP_NAME
|
|
BG_WIDTH, BG_HEIGHT = 600, 400
|
|
|
|
|
|
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
|
|
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
|
|
|
|
|
|
def make_background(path: Path) -> None:
|
|
"""程序化生成 DMG 背景图(深色,与应用图标风格一致)。"""
|
|
try:
|
|
from PIL import Image, ImageDraw
|
|
except ImportError:
|
|
return
|
|
img = Image.new("RGB", (BG_WIDTH, BG_HEIGHT), (24, 30, 42))
|
|
draw = ImageDraw.Draw(img)
|
|
# 顶部渐变更深一点,营造层次
|
|
for y in range(BG_HEIGHT // 2):
|
|
t = y / (BG_HEIGHT / 2)
|
|
color = (int(24 + 10 * t), int(30 + 12 * t), int(42 + 16 * t))
|
|
draw.line([(0, y), (BG_WIDTH, y)], fill=color)
|
|
# 一条虚线轨迹指向右侧(呼应应用图标)
|
|
for x in range(70, 520, 18):
|
|
draw.line([(x, 300), (x + 9, 300)], fill=(70, 90, 120), width=3)
|
|
# 中间箭头提示"拖过去"
|
|
draw.line([(250, 190), (330, 190)], fill=(120, 150, 190), width=4)
|
|
draw.polygon([(330, 178), (352, 190), (330, 202)], fill=(120, 150, 190))
|
|
img.save(path)
|
|
|
|
|
|
def main() -> int:
|
|
if not APP.exists():
|
|
print(f"未找到 {APP},请先运行 ./scripts/build_app.sh")
|
|
return 1
|
|
|
|
py = sys.executable
|
|
print("\033[1m制作 DMG 安装包\033[0m")
|
|
print(f" 应用:{APP}")
|
|
app_size = sum(f.stat().st_size for f in APP.rglob("*") if f.is_file()) / 1048576
|
|
print(f" 应用体积:{app_size:.1f} MB")
|
|
|
|
# 注意:/tmp 在系统只读卷上(可用空间很小),而 UDRW 中间镜像需要约 2 倍应用体积,
|
|
# 因此临时目录必须放在数据卷(用户目录)下。
|
|
staging_root = Path.home() / "Library" / "Caches" / "iosloc-dmg"
|
|
staging_root.mkdir(parents=True, exist_ok=True)
|
|
staging = Path(tempfile.mkdtemp(prefix="build-", dir=str(staging_root)))
|
|
try:
|
|
# 1) 准备素材
|
|
print("\n1) 准备 DMG 内容")
|
|
target_app = staging / f"{APP_NAME}.app"
|
|
shutil.copytree(APP, target_app, symlinks=True)
|
|
# Applications 快捷方式:用户把 app 拖进去即完成安装
|
|
os.symlink("/Applications", staging / "Applications")
|
|
bg = staging / ".background.png"
|
|
make_background(bg)
|
|
readme = staging / "安装说明.txt"
|
|
readme.write_text(
|
|
f"""{APP_NAME} v{VERSION}
|
|
========================================
|
|
|
|
安装:
|
|
把「{APP_NAME}.app」拖到右侧的 Applications 文件夹即可。
|
|
|
|
首次打开:
|
|
本工具未做 Apple 公证,若提示"来自身份不明的开发者",
|
|
请在应用上点右键 → 打开 → 再点"打开"。
|
|
首次运行还会请求"控制 Chrome/Edge"权限(用于以应用窗口方式打开界面)。
|
|
|
|
需要什么:
|
|
· macOS 12 或更高(Apple Silicon / M 系列芯片)
|
|
· 一根数据线 + 一台 iOS 设备(iOS 17 及以上需开启开发者模式并挂载开发者镜像)
|
|
· 不需要安装 Python 或任何依赖,全部已打包在应用内
|
|
|
|
数据位置:
|
|
~/Library/Application Support/iOSLocationTrack/
|
|
(配置与地图瓦片缓存;删除该目录即可重置)
|
|
|
|
完整使用说明见应用内的 README,或访问项目目录下的 README.md
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
print(f" 已放入:应用 + Applications 快捷方式 + 安装说明 + 背景图")
|
|
|
|
# 2) 生成可读写 DMG 以便布局图标
|
|
temp_dmg = staging / "temp.dmg"
|
|
print("\n2) 生成磁盘映像并设置窗口布局")
|
|
result = run([
|
|
"hdiutil", "create", "-srcfolder", str(staging), "-volname", VOLUME,
|
|
"-fs", "HFS+", "-format", "UDRW", "-ov", str(temp_dmg),
|
|
])
|
|
if result.returncode != 0:
|
|
print(" hdiutil create 失败:", result.stderr[-500:])
|
|
return 1
|
|
|
|
mount_point = Path(f"/Volumes/{VOLUME}")
|
|
if mount_point.exists():
|
|
run(["hdiutil", "detach", str(mount_point), "-force"])
|
|
result = run(["hdiutil", "attach", str(temp_dmg), "-readwrite", "-noverify", "-noautoopen"])
|
|
if result.returncode != 0:
|
|
print(" 挂载失败:", result.stderr[-400:])
|
|
return 1
|
|
time.sleep(2)
|
|
|
|
# 用 AppleScript 设置 Finder 视图(图标位置 / 背景 / 大小)
|
|
script = f'''
|
|
tell application "Finder"
|
|
tell disk "{VOLUME}"
|
|
open
|
|
set current view of container window to icon view
|
|
set toolbar visible of container window to false
|
|
set statusbar visible of container window to false
|
|
set the bounds of container window to {{200, 150, 800, 550}}
|
|
set viewOptions to the icon view options of container window
|
|
set arrangement of viewOptions to not arranged
|
|
set icon size of viewOptions to 110
|
|
try
|
|
set background picture of viewOptions to file ".background.png"
|
|
end try
|
|
set position of item "{APP_NAME}.app" of container window to {{150, 190}}
|
|
set position of item "Applications" of container window to {{450, 190}}
|
|
set position of item "安装说明.txt" of container window to {{300, 320}}
|
|
update without registering applications
|
|
delay 1
|
|
close
|
|
end tell
|
|
end tell
|
|
'''
|
|
result = run(["osascript", "-e", script])
|
|
if result.returncode != 0:
|
|
print(" 布局脚本部分失败(不影响使用):", result.stderr.strip()[:200])
|
|
|
|
# 收尾:确保 Finder 写入的 .DS_Store 落盘
|
|
run(["sync"])
|
|
time.sleep(1)
|
|
detach = run(["hdiutil", "detach", str(mount_point)])
|
|
if detach.returncode != 0:
|
|
run(["hdiutil", "detach", str(mount_point), "-force"])
|
|
time.sleep(1)
|
|
|
|
# 3) 压缩成最终 DMG
|
|
print("\n3) 压缩为最终 DMG")
|
|
if DMG.exists():
|
|
DMG.unlink()
|
|
result = run([
|
|
"hdiutil", "convert", str(temp_dmg), "-format", "UDZO",
|
|
"-imagekey", "zlib-level=9", "-o", str(DMG),
|
|
])
|
|
if result.returncode != 0:
|
|
print(" 压缩失败:", result.stderr[-400:])
|
|
return 1
|
|
finally:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
try:
|
|
shutil.rmtree(staging_root, ignore_errors=True)
|
|
except Exception:
|
|
pass
|
|
|
|
# 4) 校验
|
|
print("\n4) 校验 DMG")
|
|
size_mb = DMG.stat().st_size / 1048576
|
|
print(f" 文件:{DMG}")
|
|
print(f" 体积:{size_mb:.1f} MB")
|
|
verify_mount = Path(f"/Volumes/{VOLUME}")
|
|
result = run(["hdiutil", "attach", str(DMG), "-nobrowse", "-readonly", "-noautoopen"])
|
|
ok_mount = result.returncode == 0
|
|
print(f" 挂载:{'成功' if ok_mount else '失败 ' + result.stderr[-200:]}")
|
|
if ok_mount:
|
|
try:
|
|
time.sleep(2)
|
|
contents = sorted(p.name for p in verify_mount.iterdir())
|
|
print(f" 内容:{contents}")
|
|
has_app = (verify_mount / f"{APP_NAME}.app").exists()
|
|
has_link = (verify_mount / "Applications").is_symlink()
|
|
has_readme = (verify_mount / "安装说明.txt").exists()
|
|
binary = verify_mount / f"{APP_NAME}.app" / "Contents" / "MacOS" / "iOSLocationTrack"
|
|
print(f" 应用存在:{has_app} | Applications 快捷方式:{has_link} | 安装说明:{has_readme}")
|
|
if binary.exists():
|
|
arch = run(["lipo", "-archs", str(binary)]).stdout.strip()
|
|
print(f" 可执行架构:{arch}")
|
|
# 校验签名/完整性(未签名时的常规检查)
|
|
quarantine = run(["xattr", "-l", str(verify_mount / f"{APP_NAME}.app")]).stdout
|
|
print(f" 扩展属性:{'有' if quarantine.strip() else '无'}")
|
|
finally:
|
|
run(["hdiutil", "detach", str(verify_mount), "-force"])
|
|
|
|
print(f"\n\033[32m完成:{DMG}\033[0m")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|