From 83b6a2967f203fee92c7d0aa03c347e6ae4b74b9 Mon Sep 17 00:00:00 2001
From: DeathofBrain <109609173+DeathofBrain@users.noreply.github.com>
Date: Mon, 9 Nov 2026 22:24:08 +0800
Subject: [PATCH] feat(macos): add daily maintenance and proxy support
Rebase the macOS daily capabilities onto the current main branch, resolve guest payload changes, and keep platform-specific disk, retention, and proxy routing isolated from Windows.
---
README.en.md | 16 +-
README.md | 16 +-
internal/components/fetch.go | 4 +-
internal/emu/COMPACT.md | 12 +-
internal/emu/afterboot.go | 11 ++
internal/emu/appproxy.go | 31 +++-
internal/emu/capture_policy.go | 24 ++-
internal/emu/check.go | 21 ++-
internal/emu/crash_archive.go | 9 +-
internal/emu/crud.go | 6 +-
internal/emu/disk.go | 30 +++-
internal/emu/disk_links_darwin.go | 17 +++
internal/emu/disk_links_other.go | 2 +-
internal/emu/fileuse_other.go | 23 +++
internal/emu/gates_other_test.go | 3 +
internal/emu/launch.go | 19 ++-
internal/emu/logs.go | 19 +--
internal/emu/platform.go | 12 ++
internal/emu/retention_darwin.go | 30 ++++
internal/emu/retention_other.go | 2 +-
internal/emu/start.go | 2 +-
internal/emu/status.go | 6 +-
internal/emu/stop.go | 2 +-
internal/guest/guest.go | 39 ++++-
internal/guest/proxyd/README.md | 4 +
internal/host/bound_other.go | 2 +-
internal/host/disk_darwin.go | 20 +++
internal/host/disk_other.go | 7 +
internal/host/lock_probe_darwin.go | 7 +
internal/host/lock_probe_other.go | 5 +
internal/host/proxy_darwin.go | 12 ++
internal/host/retention_darwin.go | 14 ++
internal/host/unsupported_other.go | 6 -
internal/host/windows.go | 2 +
internal/hostproxy/guest.go | 6 +-
internal/hostproxy/hostproxy.go | 39 ++++-
internal/hostproxy/system_darwin.go | 47 ++++++
internal/hostproxy/system_other.go | 2 +-
internal/macos/disk_darwin.go | 172 ++++++++++++++++++++++
internal/macos/fileuse_darwin.go | 125 ++++++++++++++++
internal/macos/lock_darwin.go | 29 ++++
internal/macos/proxy_darwin.go | 55 +++++++
internal/macos/retention_darwin.go | 221 ++++++++++++++++++++++++++++
internal/root/platform.go | 11 ++
internal/root/root.go | 14 +-
45 files changed, 1072 insertions(+), 84 deletions(-)
create mode 100644 internal/emu/disk_links_darwin.go
create mode 100644 internal/emu/fileuse_other.go
create mode 100644 internal/emu/retention_darwin.go
create mode 100644 internal/host/disk_darwin.go
create mode 100644 internal/host/disk_other.go
create mode 100644 internal/host/lock_probe_darwin.go
create mode 100644 internal/host/lock_probe_other.go
create mode 100644 internal/host/proxy_darwin.go
create mode 100644 internal/host/retention_darwin.go
create mode 100644 internal/hostproxy/system_darwin.go
create mode 100644 internal/macos/disk_darwin.go
create mode 100644 internal/macos/fileuse_darwin.go
create mode 100644 internal/macos/proxy_darwin.go
create mode 100644 internal/macos/retention_darwin.go
diff --git a/README.en.md b/README.en.md
index 4ffb23b..5efac74 100644
--- a/README.en.md
+++ b/README.en.md
@@ -4,7 +4,7 @@
-The management program for [ModAVD](https://github.com/ModAVD/ModAVD): the graphical manager `ModAVD.exe`, and the CLI and MCP server `modavd.exe`. Written in Go, standard library only. It currently supports Windows x64 only; the platform-specific parts are already split into a platform layer. For the plans for other platforms, see [ModAVD#27](https://github.com/ModAVD/ModAVD/issues/27).
+The management program for [ModAVD](https://github.com/ModAVD/ModAVD): the graphical manager `ModAVD.exe`, and the CLI and MCP server `modavd.exe`. Written in Go, standard library only. The CLI and MCP currently support Windows x64 and Apple Silicon macOS; the graphical manager remains Windows x64 only. The platform-specific parts are already split into a platform layer. For the plans for other platforms, see [ModAVD#27](https://github.com/ModAVD/ModAVD/issues/27).
> **Version note (`main` and Releases)**: This document describes the implementation on the repository's `main`. `main` is currently at version `0.3.0`, the latest standalone Release is `v0.1.0` (matching ModAVD `0.36.0`), and `v0.2.0` has been drafted but not published. The background initialization on create, the `update` command and exit code 9, all new in `0.2.0`, are not in the `v0.1.0` download package. If you use a published package, go by the notes of the matching Release and the version inside the package.
@@ -22,7 +22,7 @@ Usage instructions and guidance for contributing are on the ModAVD documentation
## What this is
-- ModAVD is an Android emulator based on the AOSP Android Emulator plus a set of patches. It supports Windows today, and more platforms are planned. This repository holds the programs that manage it: create, change and delete instances; start and shut down; the computer check before starting; install APKs and the arm64 execution bridge; view the screen and the logs.
+- ModAVD is an Android emulator based on the AOSP Android Emulator plus a set of patches. It supports Windows and Apple Silicon macOS today, and more platforms are planned. This repository holds the programs that manage it: create, change and delete instances; start and shut down; the computer check before starting; install APKs and the arm64 execution bridge; view the screen and the logs.
- It manages one ModAVD root folder, that is, the folder the ModAVD release package unpacks to (it contains `mas-avd.json`, `sdk\` and `avd\`). The instance files and `mas-avd.json` are written the same way as in [AUTO-MAS](https://github.com/AUTO-MAS-Project/AUTO-MAS), and the tests compare against AUTO-MAS code byte for byte.
- The manager `ModAVD.exe` is for people; the CLI `modavd.exe` and its MCP server are for scripts, AUTO-MAS and AI assistants.
@@ -71,7 +71,7 @@ modavd [--root
] [--json] [--quiet] [--verbose] [args]
| `bridge install\|uninstall\|verify` | Install, uninstall or check the arm64 execution bridge; it is installed automatically at start, so you normally do not run it by hand (see below) |
| `playstore install\|uninstall\|status` | Add the Google Play Store to an instance, remove it, or check it (see below) |
| `gles32 on\|off\|status` | Experimental: make an instance report GLES 3.2 (see below) |
-| `disk compact` | Compact a stopped instance's data disk, or skip rewriting when estimated savings are too small. Verify before atomically replacing the original, and report host usage before and after, including growth. Large disks have budgets of many hours and effectively do not time out in normal use; cancel to stop an operation. Requires NTFS/ReFS, no snapshots and room for the temporary image. See [budgets and result fields](internal/emu/COMPACT.md) |
+| `disk compact` | Compact a stopped instance's data disk, or skip rewriting when estimated savings are too small. Verify before atomically replacing the original, and report host usage before and after, including growth. Large disks have budgets of many hours and effectively do not time out in normal use; cancel to stop an operation. Supports NTFS/ReFS on Windows and APFS on Apple Silicon macOS; linked images and snapshots are rejected, and room for the temporary image is required. See [budgets and result fields](internal/emu/COMPACT.md) |
| `check [--memory M]` | Computer check before starting |
| `report-config [--no-clipboard]` | Copy the computer configuration and the game info from the running instances (read-only) |
| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | Collect a problem report and zip it (read-only). Ctrl+C before ZIP completion returns `canceled`, deletes the unfinished ZIP and removes staging; `--keep-stage` preserves staging and includes its path in the error |
@@ -84,6 +84,8 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args]
- Root folder: `--root`; if not given, the environment variable `MODAVD_ROOT`; otherwise the folder containing `modavd.exe`, or its parent.
- From 0.3.0 there is no `restart`: `stop`, then `start`. An old call gets a usage error (exit code 2) that says so.
+Apple Silicon macOS uses the SDK's native `qemu-img`. Host usage counts allocated filesystem blocks, excluding sparse holes. APFS clones can share extents, so a decrease in file allocation does not guarantee the same increase in volume free space. Ordinary log cleanup checks actual open files and preserves candidates that are active, changed, linked, or contain unexpected subdirectories. The diagnostics switch remains unavailable on macOS.
+
**The arm64 execution bridge**:
- It applies only to the games on the built-in list; other apps keep using the original translation layer. The bridge is installed in the instance's own system / vendor overlay and needs an emulator from 0.36 on.
@@ -274,7 +276,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis
## Fetching the Google components
-The ModAVD release package does not include adb (platform-tools 37.0.1) or the Android 14 Google APIs x86_64 system image (r14, about 1.5 GB, about 4.2 GB unpacked): they belong to Google, are governed by the Android Software Development Kit License Agreement, and cannot be redistributed. `modavd components fetch` downloads them from official channels and installs them into `sdk\`. The manager's "补全组件" button and this command share the same logic; the first interactive run asks for a download source and remembers it:
+The ModAVD release package does not include adb (platform-tools 37.0.1) or a system image: Windows uses Android 14 Google APIs x86_64 (r14), while Apple Silicon macOS uses Android 15 Google APIs arm64-v8a (r9). They belong to Google, are governed by the Android Software Development Kit License Agreement, and cannot be redistributed. `modavd components fetch` downloads them from official channels and installs them into `sdk\`. The manager's "补全组件" button and this command share the same logic; the first interactive run asks for a download source and remembers it:
- First it fetches the full license text from `repository2-3.xml` on a mirror and displays it (stating which mirror it came from and which components will be downloaded), and continues only if you type `y`; otherwise nothing is downloaded and `downloads\` gets no extra files. `--accept-license` skips the prompt, but the full text is still fetched and displayed, and if it cannot be fetched it still exits; with `--json` it is required, otherwise the command always reports `usage` (exit code 2), before it even looks at the root folder and whether or not components are missing (up to 0.3.0 it succeeded when nothing was missing). The license text and the prompt are not affected by `--quiet`.
- Mirrors are tried in this order: googledownloads.cn → Tencent Cloud mirror → dl.google.com. When something is to be downloaded, a line "下载源:…" (download source) first says which ones this run uses and why (default / `--mirror` / `--mirror-url`). `--mirror` uses only one of them (case-insensitive); `--mirror-url` uses your own directory URL in place of those three (it can be given several times or comma-separated, and the URLs are tried in order). A mirror from which the license manifest could not be fetched is moved to the end when downloading.
@@ -282,9 +284,9 @@ The ModAVD release package does not include adb (platform-tools 37.0.1) or the A
- It unpacks to `sdk\.staging\`, and moves the result into place only after the whole package is unpacked and verified (key files, the SHA-256 pinned for the three adb files, the version); a missing `source.properties` is written in. A previously incomplete directory is renamed `.incomplete-` and kept, with `-2`, `-3` and later suffixes on collisions. If a later component fails, the JSON error's `advice` also lists the moved directories. Delete them yourself once you are sure you do not need them. Ctrl+C stops it at any time and leaves no half-installed component.
- Components that are already complete are skipped; by default the zip is deleted after a successful install, and `--keep-downloads` keeps it.
- It refuses first if the path has non-ASCII characters, if the folder is not a ModAVD root folder, if `mas-avd.json` sets `sdkRoot`, or if there is not enough space (the download plus the unpacked size, plus 500 MB spare). `--test-free-space-mb` is for tests only and pretends that only that many MB are free.
-- Timeouts are shared with `update`. `components fetch` accepts `--connect-timeout` (connecting, the TLS handshake and receiving the response headers; 1-600 seconds, default 20), `--stall-timeout` (not a single byte received; 1-600 seconds, default 30), `--manifest-timeout` (fetching the manifest as a whole; 1-1800 seconds, default 40). Proxy: if `HTTPS_PROXY` / `HTTP_PROXY` is set, it follows the environment variables; otherwise it uses a proxy set manually in the Windows "Internet Options". `NO_PROXY` applies to both; setting it alone does not disable the system proxy. Automatic configuration scripts (PAC) are not supported; in that case set `HTTPS_PROXY`.
+- Timeouts are shared with `update`. `components fetch` accepts `--connect-timeout` (connecting, the TLS handshake and receiving the response headers; 1-600 seconds, default 20), `--stall-timeout` (not a single byte received; 1-600 seconds, default 30), `--manifest-timeout` (fetching the manifest as a whole; 1-1800 seconds, default 40). Proxy: if `HTTPS_PROXY` / `HTTP_PROXY` is set, it follows the environment variables; otherwise it uses a proxy set manually in Windows "Internet Options" or the current manual HTTP/HTTPS/SOCKS settings on Apple Silicon macOS. `NO_PROXY` applies to both; setting it alone does not disable the system proxy. Automatic configuration scripts (PAC) are not supported; in that case set `HTTPS_PROXY`.
- It writes only `downloads\`, `logs\fetch-components.log` (and the `.lock` beside it; only one fetch runs at a time per root folder), the two component directories under `sdk\` (with `--play-store` and `import-playstore` also `sdk\playstore`), and `sdk\.staging\`. It does not touch the adb server, does not write the registry, and does not need administrator rights. The log records only the start and end of each download; progress is shown but not logged.
-- Only Windows has a component manifest; macOS and Linux return `unsupported` (exit code 4).
+- Windows and Apple Silicon macOS have component manifests; Linux returns `unsupported` (exit code 4).
- Exit codes: not accepting the license agreement is 2 (`license_declined`); a path with Chinese characters or not enough space is 4 (`precheck_failed`), and a folder that is not a ModAVD root folder or has `sdkRoot` set is also 4 (`not_ready`); a fetch already in progress is 5 (`busy`); failing to download the license or the components from any mirror is 9 (`components_unavailable`), and a downloaded zip whose SHA-1 is wrong on every mirror, or that fails verification after unpacking, is also 9 (`components_rejected`); being unable to read the free space is 1.
- `components status` (the default) and the components entry of `check` give the same result, and the rule for judging is the same as in AUTO-MAS (the key files being present counts as installed). `fetch` is stricter about what counts as "complete" (following the script: the key files must be non-empty, and for adb the pinned hash and version number are also verified), so it can happen that `status` shows a component as installed while `fetch` still reinstalls it, in which case the old directory is renamed and kept.
@@ -311,7 +313,7 @@ go vet ./...
go test ./...
```
-Tests run on Windows only. Optional environment variables:
+The full test suite runs on Windows and Apple Silicon macOS in CI. Optional environment variables:
| Variable | Purpose |
| --- | --- |
diff --git a/README.md b/README.md
index ecf5311..fb0b5e7 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-[ModAVD(魔改 AVD)](https://github.com/ModAVD/ModAVD) 的管理程序:图形管理器 `ModAVD.exe`,以及命令行和 MCP 服务 `modavd.exe`。Go 写,只用标准库。目前只支持 Windows x64,平台相关的部分已经拆成平台层,其他平台的安排见 [ModAVD#27](https://github.com/ModAVD/ModAVD/issues/27)。
+[ModAVD(魔改 AVD)](https://github.com/ModAVD/ModAVD) 的管理程序:图形管理器 `ModAVD.exe`,以及命令行和 MCP 服务 `modavd.exe`。Go 写,只用标准库。命令行和 MCP 目前支持 Windows x64 与 Apple Silicon macOS;图形管理器仍只支持 Windows x64,平台相关的部分已经拆成平台层,其他平台的安排见 [ModAVD#27](https://github.com/ModAVD/ModAVD/issues/27)。
> **版本提示(`main` 与 Release)**:本文按仓库 `main` 的实现编写。当前 `main` 的版本是 `0.3.0`,最新独立 Release 是 `v0.1.0`(对应 ModAVD `0.36.0`),`v0.2.0` 已起草、还没发布;`0.2.0` 起的新建后台初始化、`update` 命令和退出码 9 都不在 `v0.1.0` 下载包里。使用已发布包时,请以对应 Release 的说明和包内版本为准。
@@ -22,7 +22,7 @@
## 这是什么
-- ModAVD 是基于 AOSP Android Emulator 加一批补丁的安卓模拟器,目前支持 Windows,以后计划支持更多平台。本仓库是管理它的程序:新建、修改、删除实例,开机、关机,开机前的电脑检查,装 APK 和 arm64 执行桥,看画面和日志。
+- ModAVD 是基于 AOSP Android Emulator 加一批补丁的安卓模拟器,目前支持 Windows 和 Apple Silicon macOS,以后计划支持更多平台。本仓库是管理它的程序:新建、修改、删除实例,开机、关机,开机前的电脑检查,装 APK 和 arm64 执行桥,看画面和日志。
- 管的是一个 ModAVD 根目录,也就是 ModAVD Release 包解压出来的文件夹(里面有 `mas-avd.json`、`sdk\`、`avd\`)。实例文件和 `mas-avd.json` 的写法与 [AUTO-MAS](https://github.com/AUTO-MAS-Project/AUTO-MAS) 一致,测试里拿 AUTO-MAS 的代码逐字节对照。
- 给人用的是管理器 `ModAVD.exe`;给脚本、AUTO-MAS 和 AI 助手用的是命令行 `modavd.exe` 和它的 MCP 服务。
@@ -73,7 +73,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
| `gles32 on\|off\|status` | 实验功能:让实例报告 GLES 3.2(见下) |
| `disk trim` | 磁盘回收:在开着的实例里对数据盘触发一次 FITRIM;成功只表示触发请求已返回,不等待或确认客体回收完成;模拟器太旧、不支持回收时返回不支持(退出码 4) |
| `disk info` | 看数据盘的大小:逻辑大小、覆盖层在宿主上实际占的空间;关机且取得启动锁后才运行只读的 qcow2 元数据检查,给出已分配和估计可回收大小。整理中或锁不可用时,仅返回可读取的文件头、宿主占用和原因;不可读的数值省略,不修复镜像 |
-| `disk compact` | 整理已关机实例的数据盘;预计收益不足时跳过重写;转换后先校验再原子替换,原盘在替换成功前不动;报告前后占用和增减;大盘预算可达数十小时,正常整理基本不会超时,停止请取消;仅支持 NTFS/ReFS,拒绝快照或重写空间不足,详见[预算与结果契约](internal/emu/COMPACT.md) |
+| `disk compact` | 整理已关机实例的数据盘;预计收益不足时跳过重写;转换后先校验再原子替换,原盘在替换成功前不动;报告前后占用和增减;大盘预算可达数十小时,正常整理基本不会超时,停止请取消;Windows 支持 NTFS/ReFS,Apple Silicon macOS 支持 APFS,拒绝链接、快照或重写空间不足,详见[预算与结果契约](internal/emu/COMPACT.md) |
| `check [--memory M]` | 开机前的电脑检查 |
| `report-config [--no-clipboard]` | 复制电脑配置和开着实例里的游戏信息(只读) |
| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | 收集问题信息并打包(只读);ZIP 完成前的 Ctrl+C 返回 `canceled`,删除未完成 ZIP 并清理暂存目录;`--keep-stage` 则保留暂存目录并在错误中给出路径 |
@@ -90,6 +90,8 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
自动回收日志里的秒数是 adb/su 命令往返耗时。客体是否完成或失败,需要另看客体信号,
不能用暂时稳定的 discard 计数判定。
+Apple Silicon macOS 使用 SDK 中原生的 `qemu-img`。磁盘占用按宿主已分配块计算,排除稀疏文件空洞;APFS 克隆可能共享数据块,整理前后文件占用的差值不保证等于整个卷增加的可用空间。普通日志清理会检查实际打开的文件,保留正在使用、身份已变化、带链接或意外子目录的候选;macOS 的诊断模式开关仍未开放。
+
**arm64 执行桥**:
- 只对内置名单里的游戏生效,其余应用照旧走原来的转译层。桥装在实例自己的 system / vendor 覆盖层里,要 0.36 起的模拟器。
@@ -276,7 +278,7 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd
## 补全 Google 组件
-ModAVD 的发布包里没有 adb(平台工具 37.0.1)和 Android 14 Google APIs x86_64 系统镜像(r14,约 1.5 GB,解压后约 4.2 GB):它们属于 Google,受《Android 软件开发套件许可协议》约束,不能再分发。`modavd components fetch` 从官方渠道下载、装进 `sdk\`。管理器检测页的「补全组件」按钮和这个命令共用同一套逻辑;首次交互运行会询问下载源并记住选择:
+ModAVD 的发布包里没有 adb(平台工具 37.0.1)和系统镜像:Windows 使用 Android 14 Google APIs x86_64(r14),Apple Silicon macOS 使用 Android 15 Google APIs arm64-v8a(r9)。它们属于 Google,受《Android 软件开发套件许可协议》约束,不能再分发。`modavd components fetch` 从官方渠道下载、装进 `sdk\`。管理器检测页的「补全组件」按钮和这个命令共用同一套逻辑;首次交互运行会询问下载源并记住选择:
- 先从镜像取 `repository2-3.xml` 里的许可协议全文并显示(注明取自哪个镜像、要下载哪些组件),输入 `y` 才继续,否则什么也不下,`downloads\` 里也不多出文件。`--accept-license` 不询问,但协议全文照样取回、照样显示,取不到照样退出;`--json` 时必须给,否则一律报 `usage`(退出码 2):在看根目录之前就报,组件齐不齐都一样(0.3.0 及以前组件齐的时候会成功)。协议和提问不受 `--quiet` 影响。
- 镜像依次试 googledownloads.cn → 腾讯云镜像 → dl.google.com,要下载时先打一行「下载源:…」说明这次用哪些、为什么(默认 / `--mirror` / `--mirror-url`);`--mirror` 只用其中一个(不分大小写),`--mirror-url` 用自己的目录地址代替这三个(可写多次或用逗号隔开,按顺序试)。取不到许可协议清单的镜像,下载时排到最后。
@@ -284,9 +286,9 @@ ModAVD 的发布包里没有 adb(平台工具 37.0.1)和 Android 14 Google A
- 解压到 `sdk\.staging\`,整包解完、核对完(关键文件、adb 三个文件钉住的 SHA-256、版本)再挪到位;缺 `source.properties` 就补写。原来不完整的目录改名为 `<目录>.incomplete-<时间>` 留着,重名时加 `-2`、`-3` 等后缀;后续组件失败时,JSON 错误的 `advice` 也会列出已迁移的位置。确认没用后自己删掉。Ctrl+C 随时能停,不留下装到一半的组件。
- 已经齐全的组件跳过;默认装好就删 zip,`--keep-downloads` 保留。
- 路径里有非 ASCII 字符、不是 ModAVD 根目录、`mas-avd.json` 设了 `sdkRoot`、空间不够(下载加解压再留 500 MB),先拒绝。`--test-free-space-mb` 只给测试用,假装只剩这么多 MB。
-- 超时与 `update` 共用,`components fetch` 可用 `--connect-timeout`(连接、TLS 握手、收到响应头,1–600 秒,默认 20)、`--stall-timeout`(一个字节都没收到,1–600 秒,默认 30)、`--manifest-timeout`(取清单整体,1–1800 秒,默认 40)。代理:设了 `HTTPS_PROXY` / `HTTP_PROXY` 就照环境变量,否则用 Windows「Internet 选项」里手动设的代理;`NO_PROXY` 对两者都生效,只设它不会禁用系统代理;自动配置脚本(PAC)不支持,这时请设 `HTTPS_PROXY`。
+- 超时与 `update` 共用,`components fetch` 可用 `--connect-timeout`(连接、TLS 握手、收到响应头,1–600 秒,默认 20)、`--stall-timeout`(一个字节都没收到,1–600 秒,默认 30)、`--manifest-timeout`(取清单整体,1–1800 秒,默认 40)。代理:设了 `HTTPS_PROXY` / `HTTP_PROXY` 就照环境变量;Windows 否则用「Internet 选项」里手动设的代理,Apple Silicon macOS 则读取当前手动 HTTP/HTTPS/SOCKS 设置;`NO_PROXY` 对两者都生效,只设它不会禁用系统代理;自动配置脚本(PAC)不支持,这时请设 `HTTPS_PROXY`。
- 只写 `downloads\`、`logs\fetch-components.log`(和同目录的 `.lock`,一个根目录同时只跑一个)、`sdk\` 下的两个组件目录(`--play-store` 和 `import-playstore` 再加 `sdk\playstore`)和 `sdk\.staging\`。不碰 adb server,不写注册表,不要管理员。日志里每次下载只记开始和结束,进度只显示不记。
-- 只有 Windows 有组件清单;macOS、Linux 返回 `unsupported`(退出码 4)。
+- Windows 和 Apple Silicon macOS 有组件清单;Linux 返回 `unsupported`(退出码 4)。
- 退出码:没同意许可协议是 2(`license_declined`);路径有中文、空间不够是 4(`precheck_failed`),不是 ModAVD 根目录或设了 `sdkRoot` 也是 4(`not_ready`);正在补全是 5(`busy`);许可协议或组件从哪个镜像都没下成是 9(`components_unavailable`),下载的 zip 在每个镜像上 SHA-1 都不对、或者解压后没通过核对也是 9(`components_rejected`);读不出剩余空间是 1。
- `components status`(默认)和 `check` 的组件一项是同一份结果,判断规则和 AUTO-MAS 一致(关键文件在就算装了);`fetch` 判断「齐全」更严(照脚本:关键文件不为空,adb 还要核对钉住的哈希和版本号),所以 `status` 显示已装、`fetch` 仍会重装的情况是有的,旧目录会被改名留着。
@@ -313,7 +315,7 @@ go vet ./...
go test ./...
```
-测试只在 Windows 上跑。可选的环境变量:
+完整测试在 Windows 和 Apple Silicon macOS 的 CI 上跑。可选的环境变量:
| 变量 | 作用 |
| --- | --- |
diff --git a/internal/components/fetch.go b/internal/components/fetch.go
index f4f0864..c6f9f63 100644
--- a/internal/components/fetch.go
+++ b/internal/components/fetch.go
@@ -457,7 +457,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) {
}
if len(todo) == 0 {
if f.darwin() {
- f.say("组件都已齐全,不需要下载。当前 macOS 版本暂未开放实例创建与启动。")
+ f.say("组件都已齐全,不需要下载。创建或启动前仍需通过 HVF、镜像与模拟器版本检查。")
} else {
f.say("组件都已齐全,不需要下载。完成,可以在 AUTO-MAS 里添加这个文件夹了。")
}
@@ -532,7 +532,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) {
}
}
if f.darwin() {
- f.say("组件补全完成。当前 macOS 版本暂未开放实例创建与启动。")
+ f.say("组件补全完成。创建或启动前仍需通过 HVF、镜像与模拟器版本检查。")
} else {
f.say("完成,可以在 AUTO-MAS 里添加这个文件夹了。")
}
diff --git a/internal/emu/COMPACT.md b/internal/emu/COMPACT.md
index 26569a4..c8d457d 100644
--- a/internal/emu/COMPACT.md
+++ b/internal/emu/COMPACT.md
@@ -6,10 +6,18 @@ opens the backing chain. Corruption, a missing backing, cancellation or a failed
check cannot become a successful skip. A leftover scratch image is still removed.
The current live allocation is `allocated-clusters * cluster size`. Host usage
-comes from `GetCompressedFileSize`, so sparse or host-compressed files count their
-actual allocation. Virtual capacity comes from the qcow2 header. These measure
+comes from Windows compressed file size or macOS allocated filesystem blocks, so
+sparse holes do not count as allocated space. Virtual capacity comes from the qcow2 header. These measure
different things. A tiny overlay can inherit data across a large virtual disk.
+Windows compact supports NTFS/ReFS. Apple Silicon macOS uses the SDK's native
+`qemu-img` and requires APFS. It rejects symlinked or hardlinked overlays, checks
+the original file identity again before replacement, flushes the verified copy
+with `F_FULLFSYNC`, and atomically renames it through a held parent directory
+descriptor. APFS clones may share extents, so reported file allocation savings
+are not a guarantee of the same volume-wide free-space increase. Tool cancellation
+also terminates its process group so child tools cannot continue a canceled job.
+
The estimated saving is `max(0, host usage - live allocation)`, matching disk info.
It omits new qcow2 metadata and does not scan live clusters for zeroes. This is a
cheap estimate, not a promise of the exact saving. Compact skips rewriting when
diff --git a/internal/emu/afterboot.go b/internal/emu/afterboot.go
index 44691bd..207a636 100644
--- a/internal/emu/afterboot.go
+++ b/internal/emu/afterboot.go
@@ -114,6 +114,9 @@ func (m *Manager) afterBoot(ctx context.Context, inst *avd.Instance, mode bootSe
m.Log.Infof("实例 %d 宿主调度: %s", i, note)
},
func() {
+ if !root.CurrentPlatform().Windows() {
+ return
+ }
// 2) screenshot shared memory: SHM_videmulator only exists while streaming, and a static screen
// stays black in it, so force one SurfaceFlinger repaint afterwards (1004 needs root)
if _, err := adb.Console(ctx, port, "screenrecord webrtc start", 10*time.Second); err != nil {
@@ -124,6 +127,10 @@ func (m *Manager) afterBoot(ctx context.Context, inst *avd.Instance, mode bootSe
func() {
// 3) disk and screenshot channel state into the log (write_cache is root-only)
wc := m.su(ctx, i, "cat /sys/block/vd*/queue/write_cache | sort -u", 0)
+ if !root.CurrentPlatform().Windows() {
+ m.Log.Infof("实例 %d 磁盘缓存: %s", i, orText(joinNonEmptyLines(wc.Out, " / "), "读不到"))
+ return
+ }
shm := "缺失"
if host.SharedMemoryExists(shmName(ports, i)) {
shm = "在"
@@ -496,6 +503,10 @@ func parseWriteback(out string) (expire, writeback int, ok bool) {
}
func (m *Manager) setupBalloon(ctx context.Context, i int) {
+ if root.CurrentPlatform().MirrorOS == "macos" {
+ m.Log.Infof("实例 %d 当前 macOS 模拟器未启用空闲页上报气球", i)
+ return
+ }
const param = "/sys/module/page_reporting/parameters/page_reporting_order"
res := m.su(ctx, i, fmt.Sprintf("ls /sys/bus/virtio/drivers/virtio_balloon | grep -c virtio; echo %d > %s; cat %s",
balloonReportingOrder, param, param), 0)
diff --git a/internal/emu/appproxy.go b/internal/emu/appproxy.go
index 41ba63f..0b0432f 100644
--- a/internal/emu/appproxy.go
+++ b/internal/emu/appproxy.go
@@ -20,6 +20,7 @@ import (
"modavd/internal/guest"
"modavd/internal/hostproxy"
"modavd/internal/jsonord"
+ "modavd/internal/root"
)
// Per-app proxy (ModAVD/ModAVD#81): the listed apps of an instance go through a proxy, the rest direct. The setting is
@@ -171,7 +172,7 @@ func (m *Manager) guestUp(ctx context.Context, i int) (bool, error) {
// SetAppProxy changes instance i's per-app proxy and, while it runs, applies it at once. An address the host has to
// find (appproxy.Auto, or one without its kind) is probed on the host first.
func (m *Manager) SetAppProxy(ctx context.Context, i int, c appproxy.Change) (*AppProxyResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
if _, err := m.instance(i); err != nil {
@@ -209,9 +210,25 @@ func (m *Manager) SetAppProxy(ctx context.Context, i int, c appproxy.Change) (*A
if err != nil {
return nil, err
}
- if _, _, err := cur.Apply(c, resolve); err != nil {
+ preview, _, err := cur.Apply(c, resolve)
+ if err != nil {
return nil, appProxyError(err, found.err)
}
+ // A source build often has no ARM64 payload. Reject enabling before persisting
+ // a setting that could otherwise appear protected while no daemon can run.
+ payloadChecked := false
+ checkPayload := func(s appproxy.Setting) error {
+ if root.CurrentPlatform().MirrorOS == "macos" && s.Active() && !payloadChecked {
+ if _, err := guest.ProxyDaemonForABI(root.CurrentPlatform().GuestABI); err != nil {
+ return errs.Wrap(errs.NotReady, err, err.Error())
+ }
+ payloadChecked = true
+ }
+ return nil
+ }
+ if err := checkPayload(preview); err != nil {
+ return nil, err
+ }
var next appproxy.Setting
var notes []string
@@ -223,6 +240,11 @@ func (m *Manager) SetAppProxy(ctx context.Context, i int, c appproxy.Change) (*A
if next, notes, err = cur.Apply(c, resolve); err != nil {
return appProxyError(err, found.err)
}
+ // Another edit can change the setting after preview; validate the final
+ // value while holding the metadata lock before making it persistent.
+ if err := checkPayload(next); err != nil {
+ return err
+ }
meta.Set(appproxy.MetaKey, settingValue(meta.Get(appproxy.MetaKey), next))
return nil
})
@@ -524,11 +546,12 @@ type carried struct {
// daemonFile is the daemon of this modavd (a variable for the tests: a test build carries none).
var daemonFile = func() (carried, error) {
- b, err := guest.ProxyDaemon()
+ abi := root.CurrentPlatform().GuestABI
+ b, err := guest.ProxyDaemonForABI(abi)
if err != nil {
return carried{}, errs.Wrap(errs.NotReady, err, err.Error())
}
- local, err := guest.LocalProxyDaemon(filepath.Join(os.TempDir(), "modavd-guest"))
+ local, err := guest.LocalProxyDaemonForABI(filepath.Join(os.TempDir(), "modavd-guest"), abi)
if err != nil {
return carried{}, errs.Wrap(errs.Internal, err, "写不了客体程序的本机副本: "+err.Error())
}
diff --git a/internal/emu/capture_policy.go b/internal/emu/capture_policy.go
index 73d0c69..9f91cdb 100644
--- a/internal/emu/capture_policy.go
+++ b/internal/emu/capture_policy.go
@@ -58,18 +58,32 @@ func (m *Manager) writeCaptureState(i int, state captureState) error {
return os.Rename(tmp, path)
}
+// Native process paths may name the same file through /var and /private/var.
+// On non-Windows hosts only an existing file identity establishes ownership.
+func capturePathMatches(a, b string) bool {
+ if root.CurrentPlatform().Windows() {
+ return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
+ }
+ left, err := os.Stat(a)
+ if err != nil {
+ return false
+ }
+ right, err := os.Stat(b)
+ return err == nil && os.SameFile(left, right)
+}
+
func (m *Manager) ownsCaptureHelper(i int, state captureState) bool {
helper := host.Process{PID: state.HelperPID, Created: state.HelperCreated}
if helper.PID == 0 || helper.Created == 0 || !helper.Alive() {
return false
}
image, err := helper.ImagePath()
- if err != nil || !strings.EqualFold(filepath.Clean(image), filepath.Join(m.Root.Dir, "tools", "modavd.exe")) {
+ if err != nil || !capturePathMatches(image, filepath.Join(m.Root.Dir, "tools", root.CurrentPlatform().CLI())) {
return false
}
args, err := helper.CommandLine()
if err != nil || (len(args) != 6 && len(args) != 7) || args[1] != LogcatCaptureArg ||
- !strings.EqualFold(filepath.Clean(args[2]), m.Root.Dir) || args[3] != strconv.Itoa(i) ||
+ !capturePathMatches(args[2], m.Root.Dir) || args[3] != strconv.Itoa(i) ||
args[4] != strconv.FormatUint(uint64(state.QemuPID), 10) || args[5] != strconv.FormatUint(state.QemuCreated, 10) {
return false
}
@@ -102,7 +116,7 @@ func (m *Manager) ownedCapture(i int, state captureState) (host.Process, bool) {
return host.Process{}, false
}
image, err := client.ImagePath()
- if err != nil || !strings.EqualFold(filepath.Clean(image), filepath.Clean(m.Root.AdbExe())) {
+ if err != nil || !capturePathMatches(image, m.Root.AdbExe()) {
return host.Process{}, false
}
args, err := client.CommandLine()
@@ -125,7 +139,7 @@ func captureChildOf(client, helper host.Process) bool {
// this is a transition to wait for, not an external capture. This weaker readiness fact never permits killing it.
func (m *Manager) pendingOwnedCapture(i int, client host.Process) bool {
image, err := client.ImagePath()
- if err != nil || !strings.EqualFold(filepath.Clean(image), filepath.Clean(m.Root.AdbExe())) {
+ if err != nil || !capturePathMatches(image, m.Root.AdbExe()) {
return false
}
helper, ok := host.FindProcess(client.PPID)
@@ -200,7 +214,7 @@ func (m *Manager) waitDiagnosticCaptureRuntime(ctx context.Context, i int, timeo
}
if helperOurs && captureChildOf(client, host.Process{PID: state.HelperPID, Created: state.HelperCreated}) {
image, err := client.ImagePath()
- if err == nil && strings.EqualFold(filepath.Clean(image), filepath.Clean(m.Root.AdbExe())) {
+ if err == nil && capturePathMatches(image, m.Root.AdbExe()) {
continue
}
}
diff --git a/internal/emu/check.go b/internal/emu/check.go
index 97bb9ef..908a546 100644
--- a/internal/emu/check.go
+++ b/internal/emu/check.go
@@ -69,7 +69,7 @@ func (m *Manager) Check(ctx context.Context, opt CheckOptions) (*CheckResult, er
items := []CheckItem{
pathItem(m.Root.Dir),
componentsItem(comps, m.missingAdvice),
- emulatorItem(m.emulatorFacts()),
+ platformEmulatorItem(m.emulatorFacts()),
accel,
vulkan,
m.diskItemNow(),
@@ -107,7 +107,7 @@ func (m *Manager) precheckBeforeLaunch(ctx context.Context, memoryMB int) error
items := []CheckItem{
pathItem(m.Root.Dir),
componentsItem(comps, m.missingAdvice),
- emulatorItem(m.emulatorFacts()),
+ platformEmulatorItem(m.emulatorFacts()),
m.accelerationItem(ctx, true),
memoryItemNow(memoryMB),
m.diskItemNow(),
@@ -300,6 +300,23 @@ func (m *Manager) emulatorFacts() emulatorFacts {
return f
}
+// platformEmulatorItem keeps the Windows package contract unchanged. The macOS
+// lifecycle also supports the official ARM64 SDK emulator verified with API35;
+// other revisions require explicit validation before they can launch instances.
+func platformEmulatorItem(f emulatorFacts) CheckItem {
+ if root.CurrentPlatform().MirrorOS == "macos" && f.Present && !f.SelfBuilt {
+ ok := root.OfficialEmulatorVersionSupported(f.Version)
+ item := CheckItem{ID: "emulator", Title: "模拟器版本", OK: checkOK(ok), Blocking: true,
+ Reason: fmt.Sprintf("官方 ARM64 Emulator %s", f.Version)}
+ if !ok {
+ item.Reason = "当前官方 Emulator 版本尚未验证:" + f.Version
+ item.Advice = "macOS 当前支持官方 ARM64 Emulator 37.2.13 与 API35 arm64-v8a 镜像"
+ }
+ return item
+ }
+ return emulatorItem(f)
+}
+
// emulatorItem: only the self-built emulator of the ModAVD package (user decision: Google's builds are not
// supported), and its build must be new enough. Blocking.
func emulatorItem(f emulatorFacts) CheckItem {
diff --git a/internal/emu/crash_archive.go b/internal/emu/crash_archive.go
index 11a6036..85ab25d 100644
--- a/internal/emu/crash_archive.go
+++ b/internal/emu/crash_archive.go
@@ -67,6 +67,9 @@ func (m *Manager) crashScanPath(i int) string {
// startDiagnosticArchive starts bounded background work which survives a short CLI. Ordinary starts do not scan
// crashpad at all. Each helper owns only one instance and exits after this copy, rather than becoming a service.
func (m *Manager) startDiagnosticArchive(i int) {
+ if !root.CurrentPlatform().Windows() {
+ return
+ }
states, err := diagnostics.Read(m.Root)
if err != nil {
m.Log.Warnf("实例 %d 读不了诊断策略,未复制崩溃转储: %v", i, err)
@@ -76,7 +79,7 @@ func (m *Manager) startDiagnosticArchive(i int) {
if !state.Enabled {
return
}
- _, err = host.StartOrphan(filepath.Join(m.Root.Dir, "tools", "modavd.exe"), []string{DiagnosticArchiveArg,
+ _, err = host.StartOrphan(filepath.Join(m.Root.Dir, "tools", root.CurrentPlatform().CLI()), []string{DiagnosticArchiveArg,
m.Root.Dir, strconv.Itoa(i), state.Since, state.CreatedAt, time.Now().UTC().Format(time.RFC3339Nano)},
host.StartOptions{Env: m.ADB.Env()})
if err != nil {
@@ -88,6 +91,10 @@ func RunDiagnosticArchive(args []string, stderr io.Writer) int {
if len(args) != 5 {
return 2
}
+ if !root.CurrentPlatform().Windows() {
+ fmt.Fprintln(stderr, "当前平台尚不支持崩溃转储归档")
+ return 1
+ }
i, err := strconv.Atoi(args[1])
through, timeErr := time.Parse(time.RFC3339Nano, args[4])
if err != nil || !root.ValidIndex(i) || timeErr != nil {
diff --git a/internal/emu/crud.go b/internal/emu/crud.go
index 38b12d2..eed5779 100644
--- a/internal/emu/crud.go
+++ b/internal/emu/crud.go
@@ -32,7 +32,7 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (_ *InstanceInf
retErr = m.withCreateErrorState(index, retErr, false)
}
}()
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
if req.Resume {
@@ -197,7 +197,7 @@ func (m *Manager) resumeCreate(ctx context.Context, req CreateRequest) (*Instanc
// Update changes title / memory / cpu / fps / data partition size (next boot).
func (m *Manager) Update(ctx context.Context, i int, req UpdateRequest) (*UpdateResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
if _, err := m.instance(i); err != nil {
@@ -265,7 +265,7 @@ func (m *Manager) Update(ctx context.Context, i int, req UpdateRequest) (*Update
// without this the retry the error advises would end in "不存在". errs.NotFound only when none of mas_.ini,
// mas_.avd and instances. is there.
func (m *Manager) Delete(ctx context.Context, i int) (*DeleteResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
in, err := m.instance(i)
diff --git a/internal/emu/disk.go b/internal/emu/disk.go
index 59eb7a0..2f5f261 100644
--- a/internal/emu/disk.go
+++ b/internal/emu/disk.go
@@ -86,7 +86,7 @@ func (m *Manager) runIdleMaint(ctx context.Context, i int, timeout time.Duration
// not up, or when the emulator does not advertise discard on the data disk (an
// old build); errs.Internal when the guest command fails.
func (m *Manager) DiskTrim(ctx context.Context, i int) (*DiskTrimResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
if _, err := m.instance(i); err != nil {
@@ -279,7 +279,7 @@ func reclaimable(onDisk, allocated int64) int64 {
// back to readable header/host sizes with warnings; an unreadable size is omitted. errs.NotFound when no overlay
// exists; errs.NotReady when qemu-img is missing and a locked offline check was attempted.
func (m *Manager) DiskInfo(ctx context.Context, i int) (*DiskInfoResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
in, err := m.instance(i)
@@ -315,7 +315,7 @@ func (m *Manager) DiskInfo(ctx context.Context, i int) (*DiskInfoResult, error)
res.Warnings = append(res.Warnings, "实例在运行:已分配和可回收的大小要关机后才能算")
}
- if onDisk, err := host.GetCompressedFileSize(overlay); err != nil {
+ if onDisk, err := host.AllocatedFileBytes(overlay); err != nil {
res.Warnings = append(res.Warnings, "读不到数据盘的宿主占用:"+err.Error())
} else {
res.OnDiskBytes = new(int64(onDisk))
@@ -393,6 +393,13 @@ func requireCompactFS(overlay string) error {
if err != nil {
return err
}
+ if root.CurrentPlatform().MirrorOS == "macos" {
+ if !strings.EqualFold(fs, "APFS") {
+ return errs.Newf(errs.Unsupported, "数据盘所在卷是 %s,macOS 只能在 APFS 卷上整理磁盘", fs).
+ WithAdvice("请先备份数据,再将实例放到 APFS 卷上整理")
+ }
+ return nil
+ }
if !compactFileSystems[strings.ToUpper(fs)] {
return errs.Newf(errs.Unsupported, "数据盘所在卷是 %s,只能在 NTFS 或 ReFS 卷上整理磁盘", fs).
WithAdvice("在这种文件系统上换文件不是原子操作,中途断电可能丢数据;请把实例放到 NTFS 卷上再整理")
@@ -633,7 +640,7 @@ func (m *Manager) probeCompactAllocated(ctx context.Context, overlay string, clu
}
func compactHostBytes(path string) (int64, error) {
- n, err := host.GetCompressedFileSize(path)
+ n, err := host.AllocatedFileBytes(path)
if err != nil {
return 0, err
}
@@ -680,7 +687,7 @@ func removeCompactTmp(tmp string) error {
// errs.Busy when the instance is running or a lock is held; errs.Unsupported for a
// linked overlay, non-journaled volume, snapshots, a missing backing or too little free space.
func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
in, err := m.instance(i)
@@ -829,7 +836,18 @@ func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, e
_ = os.Remove(tmp)
return nil, err
}
- if err := host.MoveFileReplaceThrough(tmp, overlay); err != nil {
+ if root.CurrentPlatform().MirrorOS == "macos" {
+ current, err := os.Lstat(overlay)
+ if err != nil || !os.SameFile(st, current) || current.Size() != st.Size() || !current.ModTime().Equal(st.ModTime()) {
+ _ = os.Remove(tmp)
+ return nil, errs.New(errs.Busy, "整理期间原数据盘已变化,保留原盘,未替换")
+ }
+ if err := checkCompactOverlayLinks(overlay, current); err != nil {
+ _ = os.Remove(tmp)
+ return nil, err
+ }
+ }
+ if err := host.ReplaceFileDurable(tmp, overlay); err != nil {
_ = os.Remove(tmp)
return nil, err
}
diff --git a/internal/emu/disk_links_darwin.go b/internal/emu/disk_links_darwin.go
new file mode 100644
index 0000000..095f3c4
--- /dev/null
+++ b/internal/emu/disk_links_darwin.go
@@ -0,0 +1,17 @@
+//go:build darwin && arm64 && cgo
+
+package emu
+
+import (
+ "os"
+
+ "modavd/internal/errs"
+ "modavd/internal/host"
+)
+
+func checkCompactOverlayLinks(path string, info os.FileInfo) error {
+ if err := host.CheckCompactFile(path, info); err != nil {
+ return errs.Wrap(errs.Unsupported, err, err.Error()).WithAdvice("请先备份数据,再将覆盖层改为独立普通文件后整理")
+ }
+ return nil
+}
diff --git a/internal/emu/disk_links_other.go b/internal/emu/disk_links_other.go
index c83689d..59b72af 100644
--- a/internal/emu/disk_links_other.go
+++ b/internal/emu/disk_links_other.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build !windows && (!darwin || !arm64 || !cgo)
package emu
diff --git a/internal/emu/fileuse_other.go b/internal/emu/fileuse_other.go
new file mode 100644
index 0000000..44599ef
--- /dev/null
+++ b/internal/emu/fileuse_other.go
@@ -0,0 +1,23 @@
+//go:build !darwin || !arm64 || !cgo
+
+package emu
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+)
+
+func platformFileInUse(path string) (busy, vanished bool) {
+ err := os.Rename(path, path)
+ if err == nil {
+ return false, false
+ }
+ if errors.Is(err, fs.ErrNotExist) {
+ return false, true
+ }
+ if _, lerr := os.Lstat(path); errors.Is(lerr, fs.ErrNotExist) {
+ return false, true
+ }
+ return true, false
+}
diff --git a/internal/emu/gates_other_test.go b/internal/emu/gates_other_test.go
index caa1ca8..d9e870a 100644
--- a/internal/emu/gates_other_test.go
+++ b/internal/emu/gates_other_test.go
@@ -34,6 +34,9 @@ func gateTree(t *testing.T, dir string) []string {
}
func TestUnsupportedOperationsLeaveRootUntouched(t *testing.T) {
+ if adb.OwnershipSupported && root.CurrentPlatform().MirrorOS == "macos" {
+ t.Skip("native instance lifecycle is implemented on this host")
+ }
dir := testutil.TempDir(t)
r, err := root.New(dir)
if err != nil {
diff --git a/internal/emu/launch.go b/internal/emu/launch.go
index b4482a8..bd69c6b 100644
--- a/internal/emu/launch.go
+++ b/internal/emu/launch.go
@@ -28,7 +28,7 @@ const adbServerStartTimeout = 20 * time.Second
// - -audio none and -feature -VirtioSndCard: no host sound and no guest sound card (the image's virtio-snd ignores
// hw.audio*); qemu CPU on the 1999 main screen 147% -> 104%;
// - -memory: the instance's own setting (avd.MemoryFor), hw.ramSize is left alone;
-// - the balloon (free page reporting) is always on; QuickbootFileBacked has to be off for it, or the guest RAM is
+// - the Windows balloon (free page reporting) is on; QuickbootFileBacked has to be off for it, or the guest RAM is
// a file mapping whose pages qemu cannot drop;
// - VulkanBatchedDescriptorSetUpdate: without it Endfield's render thread spent 67.5% in synchronous
// vkAllocateDescriptorSets round trips;
@@ -59,7 +59,7 @@ func buildLaunchArgs(avdName string, console, memoryMB int, headless bool) []str
// Silicon. -gpu host alone selects the older host GLES translation path.
args = append(args, "-feature", "GuestAngle,Vulkan")
}
- return append(args,
+ args = append(args,
"-feature", "-QuickbootFileBacked",
"-feature", "-VirtioSndCard",
"-feature", "VulkanBatchedDescriptorSetUpdate",
@@ -69,8 +69,13 @@ func buildLaunchArgs(avdName string, console, memoryMB int, headless bool) []str
"-qemu",
"-global", "virtio-blk-device.write-cache=off",
"-global", "virtio-blk-device.config-wce=off",
- "-device", "virtio-balloon-pci,free-page-reporting=on",
)
+ // Google's ARM64 QEMU does not implement ModAVD's custom balloon property.
+ // Keep the Windows argv byte-for-byte; native macOS has no reporting balloon.
+ if root.CurrentPlatform().MirrorOS != "macos" {
+ args = append(args, "-device", "virtio-balloon-pci,free-page-reporting=on")
+ }
+ return args
}
// launchPlan is what prepareLaunch settled before emulator.exe is started.
@@ -166,8 +171,12 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM
if n, ok := inst.CPU(); ok {
cpu = strconv.Itoa(n)
}
- m.Log.Infof("启动魔改 AVD 实例 %d(%s,%dp %d Hz,%d MB / %s 核,气球开,不带声卡,ANDROID_EMU_VK_HIDE_REBAR=%s): emulator %s",
- i, mode, inst.Resolution(), inst.FPS(), memoryMB, cpu, hideRebar, strings.Join(args, " "))
+ balloon := "气球开,"
+ if root.CurrentPlatform().MirrorOS == "macos" {
+ balloon = ""
+ }
+ m.Log.Infof("启动魔改 AVD 实例 %d(%s,%dp %d Hz,%d MB / %s 核,%s不带声卡,ANDROID_EMU_VK_HIDE_REBAR=%s): emulator %s",
+ i, mode, inst.Resolution(), inst.FPS(), memoryMB, cpu, balloon, hideRebar, strings.Join(args, " "))
return &launchPlan{args: args, logPath: logPath, memoryMB: memoryMB}, nil
}
diff --git a/internal/emu/logs.go b/internal/emu/logs.go
index cde6fd3..5c2d0ae 100644
--- a/internal/emu/logs.go
+++ b/internal/emu/logs.go
@@ -181,21 +181,10 @@ func tryUnlink(path string, log *logx.Logger) unlinkResult {
return unlinkFailed
}
-// fileInUse: a handle without delete sharing holds the file (the emulator's and logcat's output handles are such
-// handles), so it cannot be deleted. A rename onto itself needs delete access like a delete, so it finds those files
-// without changing them (AUTO-MAS _in_use). vanished when the file is not there.
+// fileInUse checks native open-vnode ownership on macOS. Windows retains its
+// rename-to-self delete-sharing probe (AUTO-MAS _in_use). vanished means missing.
func fileInUse(path string) (busy, vanished bool) {
- err := os.Rename(path, path)
- if err == nil {
- return false, false
- }
- if errors.Is(err, fs.ErrNotExist) {
- return false, true
- }
- if _, lerr := os.Lstat(path); errors.Is(lerr, fs.ErrNotExist) {
- return false, true
- }
- return true, false
+ return platformFileInUse(path)
}
// ---- logcat capture ------------------------------------------------------------------------------------------------
@@ -305,7 +294,7 @@ func (m *Manager) ensureLogcatRuntime(ctx context.Context, i int) string {
return ""
}
// The writer must outlive this CLI, just like adb. The helper owns both the pipe and the rotating files.
- exe := filepath.Join(m.Root.Dir, "tools", "modavd.exe")
+ exe := filepath.Join(m.Root.Dir, "tools", root.CurrentPlatform().CLI())
token, err := captureRuntimeToken(m.Root)
if err != nil {
return ""
diff --git a/internal/emu/platform.go b/internal/emu/platform.go
index a151132..9c3b7f1 100644
--- a/internal/emu/platform.go
+++ b/internal/emu/platform.go
@@ -1,6 +1,7 @@
package emu
import (
+ "modavd/internal/adb"
"modavd/internal/errs"
"modavd/internal/root"
)
@@ -14,3 +15,14 @@ func requireWindows() error {
}
return nil
}
+
+// requireLifecycle only opens shared instance operations when the host can prove
+// QEMU ownership, lock files and query ports. In particular a no-cgo Darwin build
+// must fail before creating instance metadata or partial initialization state.
+func requireLifecycle() error {
+ p := root.CurrentPlatform()
+ if p.Windows() || (p.MirrorOS == "macos" && adb.OwnershipSupported) {
+ return nil
+ }
+ return errs.New(errs.Unsupported, "当前平台暂不支持实例管理")
+}
diff --git a/internal/emu/retention_darwin.go b/internal/emu/retention_darwin.go
new file mode 100644
index 0000000..b9432b0
--- /dev/null
+++ b/internal/emu/retention_darwin.go
@@ -0,0 +1,30 @@
+//go:build darwin && arm64 && cgo
+
+package emu
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+
+ "modavd/internal/host"
+ "modavd/internal/logx"
+)
+
+func snapshotLog(path string) (fs.FileInfo, error) { return os.Lstat(path) }
+
+func unlinkRetained(candidate logFile, log *logx.Logger) unlinkResult {
+ if err := host.UnlinkRetained(candidate.path, candidate.info, candidate.dir); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return unlinkGone
+ }
+ log.Debugf("旧日志 %s 不能安全删除: %v", candidate.name, err)
+ return unlinkFailed
+ }
+ return unlinkRemoved
+}
+
+// Diagnostics mutation remains gated; this stage only enables ordinary retention.
+func holdDiagnosticDirectory(string, fs.FileInfo) (*os.File, error) { return nil, os.ErrPermission }
+
+func platformFileInUse(path string) (bool, bool) { return host.FileInUse(path) }
diff --git a/internal/emu/retention_other.go b/internal/emu/retention_other.go
index f7ada65..9747d66 100644
--- a/internal/emu/retention_other.go
+++ b/internal/emu/retention_other.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build !windows && (!darwin || !arm64 || !cgo)
// Copyright 2026 Ji Mao Cai
package emu
diff --git a/internal/emu/start.go b/internal/emu/start.go
index a00947e..04a5585 100644
--- a/internal/emu/start.go
+++ b/internal/emu/start.go
@@ -86,7 +86,7 @@ var startFindQemu = (*Manager).findQemu
// the guest reboots on its own (qemu stays), the watch runs this start again, and so does any start that finds the
// recorded boot_id changed. The per-boot steps then run again and the guest library fixes are owed as above.
func (m *Manager) Start(ctx context.Context, i int, opt StartOptions) (*StartResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
t0 := time.Now()
diff --git a/internal/emu/status.go b/internal/emu/status.go
index 4912332..511830d 100644
--- a/internal/emu/status.go
+++ b/internal/emu/status.go
@@ -51,7 +51,7 @@ func (m *Manager) qemuOf(i int) (host.Process, bool, error) {
// the process scan, creation metadata / locks need no adb: List would start the
// private server and ask each instance, up to 5 s out of a 30 s shutdown budget.
func (m *Manager) StopTargets(ctx context.Context) ([]int, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
if err := ctxErr(ctx); err != nil {
@@ -84,7 +84,7 @@ func (m *Manager) StopTargets(ctx context.Context) ([]int, error) {
// List is every instance with its status (one process scan, getprop only for running ones).
func (m *Manager) List(ctx context.Context) (*ListResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
insts, err := avd.List(m.Root)
@@ -120,7 +120,7 @@ func (m *Manager) List(ctx context.Context) (*ListResult, error) {
// Info is one instance. errs.NotFound when it does not exist.
func (m *Manager) Info(ctx context.Context, i int) (*InstanceInfo, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
in, err := m.instance(i)
diff --git a/internal/emu/stop.go b/internal/emu/stop.go
index 01534a6..08b15f7 100644
--- a/internal/emu/stop.go
+++ b/internal/emu/stop.go
@@ -89,7 +89,7 @@ var stopOpsOf = (*Manager).stopOpsFor
// end qemu (see qemuNotEnded). An external stop of a creating instance is errs.Busy unless CancelCreate is explicit;
// Force only selects the shutdown method. Internal setup shutdowns and bridge restarts use creating instead.
func (m *Manager) Stop(ctx context.Context, i int, opt StopOptions) (*StopResult, error) {
- if err := requireWindows(); err != nil {
+ if err := requireLifecycle(); err != nil {
return nil, err
}
in, err := m.instance(i)
diff --git a/internal/guest/guest.go b/internal/guest/guest.go
index e872e30..061ca96 100644
--- a/internal/guest/guest.go
+++ b/internal/guest/guest.go
@@ -6,6 +6,7 @@ package guest
import (
"bytes"
"crypto/sha256"
+ "debug/elf"
"embed"
"encoding/hex"
"errors"
@@ -32,17 +33,45 @@ var ErrNoProxyDaemon = errors.New("这个 modavd 编的时候没带按应用走
// ProxyDaemon is cmd/modavd-proxyd for linux/amd64, as build.ps1 embedded it. It is embedded as is, not compressed:
// a large compressed blob inside an exe is what antivirus models take for a dropper's payload.
-func ProxyDaemon() ([]byte, error) {
- b, err := proxyd.ReadFile("proxyd/" + ProxyDaemonName)
+func ProxyDaemon() ([]byte, error) { return ProxyDaemonForABI("x86_64") }
+
+// ProxyDaemonForABI selects a packaged Linux guest executable. The historical
+// filename stays reserved for x86_64; ARM64 never falls back to that executable.
+func ProxyDaemonForABI(abi string) ([]byte, error) {
+ name := ProxyDaemonName
+ machine := elf.EM_X86_64
+ switch abi {
+ case "x86_64":
+ case "arm64-v8a", "arm64", "aarch64":
+ name += "-arm64"
+ machine = elf.EM_AARCH64
+ default:
+ return nil, fmt.Errorf("不支持的客体代理 ABI:%s", abi)
+ }
+ b, err := proxyd.ReadFile("proxyd/" + name)
+ if err != nil {
+ if abi == "x86_64" {
+ return nil, ErrNoProxyDaemon
+ }
+ return nil, fmt.Errorf("此构建未附带 ARM64 客体代理程序 %s", name)
+ }
+ exe, err := elf.NewFile(bytes.NewReader(b))
if err != nil {
- return nil, ErrNoProxyDaemon
+ return nil, fmt.Errorf("客体代理不是 ELF:%w", err)
+ }
+ defer exe.Close()
+ if exe.Class != elf.ELFCLASS64 || exe.Data != elf.ELFDATA2LSB || exe.Machine != machine ||
+ (exe.Type != elf.ET_EXEC && exe.Type != elf.ET_DYN) {
+ return nil, fmt.Errorf("客体代理的 ELF 架构与 %s 不匹配", abi)
}
return b, nil
}
// LocalProxyDaemon is LocalFile for the daemon.
-func LocalProxyDaemon(dir string) (string, error) {
- b, err := ProxyDaemon()
+func LocalProxyDaemon(dir string) (string, error) { return LocalProxyDaemonForABI(dir, "x86_64") }
+
+func LocalProxyDaemonForABI(dir, abi string) (string, error) {
+ b, err := ProxyDaemonForABI(abi)
if err != nil {
return "", err
}
diff --git a/internal/guest/proxyd/README.md b/internal/guest/proxyd/README.md
index 8ec7a4c..ebeb1b1 100644
--- a/internal/guest/proxyd/README.md
+++ b/internal/guest/proxyd/README.md
@@ -2,3 +2,7 @@
embeds this directory, so both exes carry the guest daemon of the per-app proxy. It goes in uncompressed: a large
compressed blob inside an exe is what antivirus models take for a dropper's payload. A build without `build.ps1`
(`go build`, `go test`) has none: `guest.ProxyDaemon` then returns `ErrNoProxyDaemon`.
+
+ARM64 packages may add `modavd-proxyd-arm64` from the same source built for linux/arm64. Selection follows the guest ABI
+and verifies the ELF class, endianness and machine before publishing a host copy. A missing ARM64 payload is reported
+explicitly; the x86_64 payload is never used as fallback. No generated payload is tracked in Git.
diff --git a/internal/host/bound_other.go b/internal/host/bound_other.go
index 4d75bfd..da1f167 100644
--- a/internal/host/bound_other.go
+++ b/internal/host/bound_other.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build !windows && (!darwin || !arm64 || !cgo)
package host
diff --git a/internal/host/disk_darwin.go b/internal/host/disk_darwin.go
new file mode 100644
index 0000000..168f34b
--- /dev/null
+++ b/internal/host/disk_darwin.go
@@ -0,0 +1,20 @@
+//go:build darwin && arm64 && cgo
+
+package host
+
+import (
+ "context"
+ "os"
+
+ "modavd/internal/macos"
+)
+
+func AllocatedFileBytes(path string) (uint64, error) { return macos.AllocatedFileBytes(path) }
+func VolumeFileSystem(path string) (string, error) { return macos.VolumeFileSystem(path) }
+func ReplaceFileDurable(src, dst string) error { return macos.ReplaceFileDurable(src, dst) }
+func CheckCompactFile(path string, expected os.FileInfo) error {
+ return macos.CheckCompactFile(path, expected)
+}
+func RunBound(ctx context.Context, exe string, args ...string) ([]byte, []byte, *os.ProcessState, error) {
+ return macos.RunBound(ctx, exe, args...)
+}
diff --git a/internal/host/disk_other.go b/internal/host/disk_other.go
new file mode 100644
index 0000000..3fa5ecc
--- /dev/null
+++ b/internal/host/disk_other.go
@@ -0,0 +1,7 @@
+//go:build !windows && (!darwin || !arm64 || !cgo)
+
+package host
+
+func AllocatedFileBytes(string) (uint64, error) { return 0, errUnsupported }
+func VolumeFileSystem(string) (string, error) { return "", errUnsupported }
+func ReplaceFileDurable(string, string) error { return errUnsupported }
diff --git a/internal/host/lock_probe_darwin.go b/internal/host/lock_probe_darwin.go
new file mode 100644
index 0000000..8d2c576
--- /dev/null
+++ b/internal/host/lock_probe_darwin.go
@@ -0,0 +1,7 @@
+//go:build darwin && cgo
+
+package host
+
+import "modavd/internal/macos"
+
+func ExclusiveLockHeld(path string) (bool, error) { return macos.ExclusiveLockHeld(path) }
diff --git a/internal/host/lock_probe_other.go b/internal/host/lock_probe_other.go
new file mode 100644
index 0000000..56da06a
--- /dev/null
+++ b/internal/host/lock_probe_other.go
@@ -0,0 +1,5 @@
+//go:build !windows && (!darwin || !cgo)
+
+package host
+
+func ExclusiveLockHeld(string) (bool, error) { return false, errUnsupported }
diff --git a/internal/host/proxy_darwin.go b/internal/host/proxy_darwin.go
new file mode 100644
index 0000000..217767d
--- /dev/null
+++ b/internal/host/proxy_darwin.go
@@ -0,0 +1,12 @@
+//go:build darwin && cgo
+
+package host
+
+import (
+ "context"
+ "modavd/internal/macos"
+)
+
+func SystemProxy(ctx context.Context) (map[string]string, []string, error) {
+ return macos.SystemProxy(ctx)
+}
diff --git a/internal/host/retention_darwin.go b/internal/host/retention_darwin.go
new file mode 100644
index 0000000..4a5330d
--- /dev/null
+++ b/internal/host/retention_darwin.go
@@ -0,0 +1,14 @@
+//go:build darwin && arm64 && cgo
+
+package host
+
+import (
+ "os"
+
+ "modavd/internal/macos"
+)
+
+func FileInUse(path string) (bool, bool) { return macos.FileInUse(path) }
+func UnlinkRetained(path string, expected os.FileInfo, directory bool) error {
+ return macos.UnlinkRetained(path, expected, directory)
+}
diff --git a/internal/host/unsupported_other.go b/internal/host/unsupported_other.go
index 75f4987..46f9525 100644
--- a/internal/host/unsupported_other.go
+++ b/internal/host/unsupported_other.go
@@ -41,9 +41,6 @@ func SharedMemoryExists(string) bool { return false }
// GetCompressedFileSize calls GetCompressedFileSizeW for the Windows file.
func GetCompressedFileSize(string) (uint64, error) { return 0, errUnsupported }
-// VolumeFileSystem calls GetVolumePathNameW + GetVolumeInformationW on Windows.
-func VolumeFileSystem(string) (string, error) { return "", errUnsupported }
-
// MoveFileReplaceThrough calls MoveFileExW (REPLACE_EXISTING | WRITE_THROUGH).
func MoveFileReplaceThrough(string, string) error { return errUnsupported }
@@ -68,7 +65,4 @@ func OpenStableRead(string) (*os.File, error) { return nil, errUnsupported }
// FileChangeStamp disables caching without a supported file identity API.
func FileChangeStamp(*os.File) string { return "" }
-// ExclusiveLockHeld requires the Windows lifecycle lock probe.
-func ExclusiveLockHeld(string) (bool, error) { return false, errUnsupported }
-
func InfoBox(string) error { return errors.New("当前平台暂不支持消息框") }
diff --git a/internal/host/windows.go b/internal/host/windows.go
index 1f7702b..dfe1bb8 100644
--- a/internal/host/windows.go
+++ b/internal/host/windows.go
@@ -64,6 +64,8 @@ func L3Groups() []L3Group { return win.L3Groups()
func SharedMemoryExists(name string) bool { return win.SharedMemoryExists(name) }
func DiskFree(path string) (uint64, error) { return win.DiskFree(path) }
func GetCompressedFileSize(path string) (uint64, error) { return win.GetCompressedFileSize(path) }
+func AllocatedFileBytes(path string) (uint64, error) { return win.GetCompressedFileSize(path) }
+func ReplaceFileDurable(src, dst string) error { return win.MoveFileReplaceThrough(src, dst) }
func VolumeFileSystem(path string) (string, error) { return win.VolumeFileSystem(path) }
func MoveFileReplaceThrough(src, dst string) error { return win.MoveFileReplaceThrough(src, dst) }
func AvailableMemoryMB() (uint64, error) { return win.AvailableMemoryMB() }
diff --git a/internal/hostproxy/guest.go b/internal/hostproxy/guest.go
index 3d9b08f..f7509df 100644
--- a/internal/hostproxy/guest.go
+++ b/internal/hostproxy/guest.go
@@ -41,7 +41,11 @@ func GuestSuggestion() (Suggestion, bool) {
if !st.enabled {
return Suggestion{}, false
}
- return fromProxyServer(st.server)
+ s, ok := fromProxyServer(st.server)
+ if ok && st.from != "" {
+ s.From = st.from
+ }
+ return s, ok
}
// fromProxyServer picks from the Internet settings' ProxyServer.
diff --git a/internal/hostproxy/hostproxy.go b/internal/hostproxy/hostproxy.go
index d460637..d5e9acd 100644
--- a/internal/hostproxy/hostproxy.go
+++ b/internal/hostproxy/hostproxy.go
@@ -37,6 +37,12 @@ func ForRequest(req *http.Request) (*url.URL, error) {
if !s.enabled {
return nil, nil
}
+ if s.bypassSimpleHosts && net.ParseIP(req.URL.Hostname()) == nil && !strings.Contains(req.URL.Hostname(), ".") {
+ return nil, nil
+ }
+ if s.bypassNetworks && darwinBypassCIDR(req.URL.Hostname(), s.override) {
+ return nil, nil
+ }
return proxyFor(req.URL, s.server, s.override), nil
}
@@ -109,9 +115,12 @@ func Advice() string {
// settings are the Internet settings that matter here.
type settings struct {
- enabled bool // ProxyEnable and a ProxyServer
- server, override string // ProxyServer, ProxyOverride
- autoConfig bool // AutoConfigURL is set
+ enabled bool // ProxyEnable and a ProxyServer
+ server, override string // ProxyServer, ProxyOverride
+ autoConfig bool // AutoConfigURL is set
+ from string // platform description; empty retains the Windows label
+ bypassNetworks bool // macOS IP-network exceptions; Windows overrides keep their semantics
+ bypassSimpleHosts bool // macOS simple hostnames exclude IP literals
}
// system reads them (a variable for the tests).
@@ -194,3 +203,27 @@ func wildcard(pattern, s string) bool {
}
return i == len(pattern)
}
+
+// macOS accepts short IPv4 networks such as 169.254/16 in its exception list.
+// Apply these only to the macOS dictionary, retaining Windows override semantics.
+func darwinBypassCIDR(host, exceptions string) bool {
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return false
+ }
+ for _, pattern := range strings.Split(exceptions, ";") {
+ addr, bits, ok := strings.Cut(strings.TrimSpace(pattern), "/")
+ if !ok {
+ continue
+ }
+ if !strings.Contains(addr, ":") {
+ for strings.Count(addr, ".") < 3 {
+ addr += ".0"
+ }
+ }
+ if _, subnet, err := net.ParseCIDR(addr + "/" + bits); err == nil && subnet.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/hostproxy/system_darwin.go b/internal/hostproxy/system_darwin.go
new file mode 100644
index 0000000..4dc179c
--- /dev/null
+++ b/internal/hostproxy/system_darwin.go
@@ -0,0 +1,47 @@
+//go:build darwin && cgo
+
+package hostproxy
+
+import (
+ "context"
+ "net"
+ "strconv"
+ "strings"
+ "time"
+
+ "modavd/internal/host"
+)
+
+// readSystem uses macOS's active manual HTTP/HTTPS/SOCKS settings. PAC and
+// auto-discovery are reported for the existing HTTPS_PROXY advice, not executed.
+func readSystem() settings {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ v, exceptions, err := host.SystemProxy(ctx)
+ if err != nil {
+ return settings{}
+ }
+ return darwinSettings(v, exceptions)
+}
+
+func darwinSettings(v map[string]string, exceptions []string) settings {
+ s := settings{bypassNetworks: true, autoConfig: v["ProxyAutoConfigEnable"] == "1" || v["ProxyAutoDiscoveryEnable"] == "1"}
+ var entries []string
+ for _, p := range []struct{ key, scheme string }{{"HTTP", "http"}, {"HTTPS", "https"}, {"SOCKS", "socks"}} {
+ n, err := strconv.Atoi(v[p.key+"Port"])
+ addr := v[p.key+"Proxy"]
+ if v[p.key+"Enable"] != "1" || addr == "" || n < 1 || n > 65535 || err != nil {
+ continue
+ }
+ endpoint := net.JoinHostPort(strings.Trim(addr, "[]"), strconv.Itoa(n))
+ entries = append(entries, p.scheme+"="+endpoint)
+ if p.scheme == "socks" {
+ entries = append(entries, "socks5://"+endpoint)
+ }
+ }
+ s.enabled, s.server = len(entries) > 0, strings.Join(entries, ";")
+ s.bypassSimpleHosts = v["ExcludeSimpleHostnames"] == "1"
+ s.override = strings.Join(exceptions, ";")
+ s.from = "macOS 代理设置"
+ return s
+}
diff --git a/internal/hostproxy/system_other.go b/internal/hostproxy/system_other.go
index 69e939c..416d26c 100644
--- a/internal/hostproxy/system_other.go
+++ b/internal/hostproxy/system_other.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build !windows && (!darwin || !cgo)
package hostproxy
diff --git a/internal/macos/disk_darwin.go b/internal/macos/disk_darwin.go
new file mode 100644
index 0000000..ee15003
--- /dev/null
+++ b/internal/macos/disk_darwin.go
@@ -0,0 +1,172 @@
+//go:build darwin && arm64 && cgo
+
+package macos
+
+/*
+#include
+#include
+#include
+#include
+#include
+static int daily_openat(int dirfd, const char *name, int flags) {
+ return openat(dirfd, name, flags | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
+}
+static int daily_renameat(int dirfd, const char *src, const char *dst) {
+ return renameat(dirfd, src, dirfd, dst) == 0 ? 0 : errno;
+}
+*/
+import "C"
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+func dailyOpenAt(dir *os.File, name string, flags int) (*os.File, error) {
+ if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") {
+ return nil, syscall.EINVAL
+ }
+ path := C.CString(name)
+ defer C.free(unsafe.Pointer(path))
+ fd, err := C.daily_openat(C.int(dir.Fd()), path, C.int(flags))
+ if fd < 0 {
+ return nil, err
+ }
+ return os.NewFile(uintptr(fd), filepath.Join(dir.Name(), name)), nil
+}
+
+func dailyOpenDirectory(path string) (*os.File, error) {
+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, err
+ }
+ return os.NewFile(uintptr(fd), path), nil
+}
+
+// AllocatedFileBytes counts host filesystem blocks, excluding sparse-file holes.
+// APFS shared extents can remain allocated after replacement; this is a snapshot
+// of the file's allocation, not a promise of a volume-wide free-space increase.
+func AllocatedFileBytes(path string) (uint64, error) {
+ var stat syscall.Stat_t
+ if err := syscall.Stat(path, &stat); err != nil {
+ return 0, err
+ }
+ if stat.Blocks < 0 || uint64(stat.Blocks) > math.MaxUint64/512 {
+ return 0, fmt.Errorf("文件分配块数无效: %s", path)
+ }
+ return uint64(stat.Blocks) * 512, nil
+}
+
+func VolumeFileSystem(path string) (string, error) {
+ var stat syscall.Statfs_t
+ if err := syscall.Statfs(path, &stat); err != nil {
+ return "", err
+ }
+ name := make([]byte, 0, len(stat.Fstypename))
+ for _, b := range stat.Fstypename {
+ if b == 0 {
+ break
+ }
+ name = append(name, byte(b))
+ }
+ return string(name), nil
+}
+
+func plainSingleFile(info os.FileInfo) bool {
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ return info.Mode().IsRegular() && ok && stat.Nlink == 1
+}
+
+// CheckCompactFile refuses symlinks and hardlinks before any rewrite occurs.
+func CheckCompactFile(path string, expected os.FileInfo) error {
+ dir, err := dailyOpenDirectory(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ f, err := dailyOpenAt(dir, filepath.Base(path), syscall.O_RDONLY)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ if !plainSingleFile(info) || !os.SameFile(expected, info) {
+ return fmt.Errorf("数据盘必须是独立普通文件,不能是符号链接、硬链接或已替换的文件: %s", path)
+ }
+ return nil
+}
+
+// ReplaceFileDurable flushes the verified copy, then replaces through one held
+// directory descriptor. APFS rename is atomic; no remove-then-rename gap exists.
+func ReplaceFileDurable(src, dst string) error {
+ if filepath.Clean(src) == filepath.Clean(dst) || filepath.Clean(filepath.Dir(src)) != filepath.Clean(filepath.Dir(dst)) {
+ return fmt.Errorf("磁盘替换必须在同一目录内")
+ }
+ dir, err := dailyOpenDirectory(filepath.Dir(dst))
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ f, err := dailyOpenAt(dir, filepath.Base(src), syscall.O_RDWR)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil || !plainSingleFile(info) {
+ return fmt.Errorf("整理副本不是独立普通文件: %s", src)
+ }
+ old, err := dailyOpenAt(dir, filepath.Base(dst), syscall.O_RDONLY)
+ if err != nil {
+ return err
+ }
+ defer old.Close()
+ oldInfo, err := old.Stat()
+ if err != nil || !plainSingleFile(oldInfo) {
+ return fmt.Errorf("原数据盘不是独立普通文件: %s", dst)
+ }
+ if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, f.Fd(), syscall.F_FULLFSYNC, 0); errno != 0 {
+ return fmt.Errorf("刷新整理副本失败: %w", errno)
+ }
+ a, b := C.CString(filepath.Base(src)), C.CString(filepath.Base(dst))
+ defer C.free(unsafe.Pointer(a))
+ defer C.free(unsafe.Pointer(b))
+ if code := C.daily_renameat(C.int(dir.Fd()), a, b); code != 0 {
+ return syscall.Errno(code)
+ }
+ if err := dir.Sync(); err != nil {
+ return fmt.Errorf("数据盘已原子替换,但目录刷新失败: %w", err)
+ }
+ return nil
+}
+
+// RunBound owns a new process group so cancellation also stops tool children.
+func RunBound(ctx context.Context, exe string, args ...string) ([]byte, []byte, *os.ProcessState, error) {
+ cmd := exec.CommandContext(ctx, exe, args...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ if errors.Is(err, syscall.ESRCH) {
+ return os.ErrProcessDone
+ }
+ return err
+ }
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &stdout, &stderr
+ cmd.WaitDelay = 2 * time.Second
+ err := cmd.Run()
+ return stdout.Bytes(), stderr.Bytes(), cmd.ProcessState, err
+}
diff --git a/internal/macos/fileuse_darwin.go b/internal/macos/fileuse_darwin.go
new file mode 100644
index 0000000..0e4db62
--- /dev/null
+++ b/internal/macos/fileuse_darwin.go
@@ -0,0 +1,125 @@
+//go:build darwin && arm64 && cgo
+
+package macos
+
+/*
+#include
+#include
+#include
+#include
+static int daily_file_open(int pid, uint32_t dev, uint64_t ino, int *found) {
+ *found = 0;
+ for (int attempt = 0; attempt < 4; attempt++) {
+ int size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0);
+ if (size < 0) return errno ? errno : EIO;
+ if (size == 0) return 0;
+ if (size > 8 * 1024 * 1024) return EOVERFLOW;
+ size += 64 * sizeof(struct proc_fdinfo);
+ struct proc_fdinfo *fds = malloc(size);
+ if (!fds) return ENOMEM;
+ int used = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, size);
+ if (used <= 0) { int e = errno; free(fds); return e ? e : EIO; }
+ if (used >= size) { free(fds); continue; }
+ int count = used / sizeof(struct proc_fdinfo);
+ for (int i = 0; i < count; i++) {
+ if (fds[i].proc_fdtype != PROX_FDTYPE_VNODE) continue;
+ struct vnode_fdinfo info;
+ errno = 0;
+ int n = proc_pidfdinfo(pid, fds[i].proc_fd, PROC_PIDFDVNODEINFO, &info, sizeof(info));
+ if (n != sizeof(info)) {
+ int e = errno;
+ if (e == EBADF || e == ENOENT || e == EINVAL) continue;
+ free(fds); return e ? e : EIO;
+ }
+ if (info.pvi.vi_stat.vst_dev == dev && info.pvi.vi_stat.vst_ino == ino) {
+ *found = 1; free(fds); return 0;
+ }
+ }
+ free(fds); return 0;
+ }
+ return EAGAIN;
+}
+*/
+import "C"
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+)
+
+// FileInUse protects actual open vnodes, including files renamed by another
+// process. Every process read is bracketed by its PID/start identity. Unknown
+// descriptors of a possible managed writer conservatively prevent deletion.
+// Protected macOS services outside the package can deny descriptor inspection;
+// their denial does not turn every closed manager log into an active one.
+func FileInUse(path string) (busy, vanished bool) {
+ return fileInUse(path, path)
+}
+
+func fileInUse(path, writerPath string) (busy, vanished bool) {
+ info, err := os.Lstat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, true
+ }
+ if err != nil || info.Mode()&os.ModeSymlink != 0 {
+ return true, false
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return true, false
+ }
+ processes, err := ListProcesses()
+ if err != nil {
+ return true, false
+ }
+ for _, p := range processes {
+ before, err := p.checkedInfo()
+ if errors.Is(err, os.ErrProcessDone) {
+ continue
+ }
+ if err != nil {
+ return true, false
+ }
+ if uint32(before.pbi_uid) != uint32(os.Geteuid()) {
+ continue
+ }
+ var found C.int
+ code := C.daily_file_open(C.int(p.PID), C.uint32_t(stat.Dev), C.uint64_t(stat.Ino), &found)
+ if _, err := p.checkedInfo(); errors.Is(err, os.ErrProcessDone) {
+ continue
+ } else if err != nil {
+ return true, false
+ }
+ if code != 0 {
+ if possibleLogWriter(p, writerPath) {
+ return true, false
+ }
+ continue
+ }
+ if found != 0 {
+ return true, false
+ }
+ }
+ return false, false
+}
+
+func possibleLogWriter(p Process, path string) bool {
+ name := strings.ToLower(p.Name)
+ if p.PID == uint32(os.Getpid()) || p.PPID == uint32(os.Getpid()) || strings.HasPrefix(name, "qemu") ||
+ strings.HasPrefix(name, "emulator") || strings.HasPrefix(name, "adb") || strings.Contains(name, "modavd") {
+ return true
+ }
+ image, err := p.ImagePath()
+ if err != nil {
+ return false
+ }
+ rootDir := filepath.Dir(filepath.Dir(path))
+ if real, err := filepath.EvalSymlinks(rootDir); err == nil {
+ rootDir = real
+ }
+ rel, err := filepath.Rel(rootDir, image)
+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
+}
diff --git a/internal/macos/lock_darwin.go b/internal/macos/lock_darwin.go
index 9ee156f..cab4195 100644
--- a/internal/macos/lock_darwin.go
+++ b/internal/macos/lock_darwin.go
@@ -57,3 +57,32 @@ func (l *FileLock) Unlock() {
_ = l.f.Close() // closing also releases the lock when explicit unlocking fails
l.f = nil
}
+
+// ExclusiveLockHeld is a read-only shared-lock probe. Missing files are free;
+// never create a file or directory just to inspect lifecycle state.
+func ExclusiveLockHeld(path string) (bool, error) {
+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ f := os.NewFile(uintptr(fd), path)
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil {
+ return false, err
+ }
+ if !st.Mode().IsRegular() {
+ return false, fmt.Errorf("锁文件不是普通文件")
+ }
+ err = syscall.Flock(fd, syscall.LOCK_SH|syscall.LOCK_NB)
+ if err == nil {
+ return false, syscall.Flock(fd, syscall.LOCK_UN)
+ }
+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
+ return true, nil
+ }
+ return false, err
+}
diff --git a/internal/macos/proxy_darwin.go b/internal/macos/proxy_darwin.go
new file mode 100644
index 0000000..4c3a438
--- /dev/null
+++ b/internal/macos/proxy_darwin.go
@@ -0,0 +1,55 @@
+//go:build darwin && cgo
+
+package macos
+
+import (
+ "context"
+ "os/exec"
+ "strings"
+)
+
+// SystemProxy reads the active macOS proxy dictionary without changing network
+// services. Scoped per-interface dictionaries do not override the global one.
+func SystemProxy(ctx context.Context) (map[string]string, []string, error) {
+ b, err := exec.CommandContext(ctx, "/usr/sbin/scutil", "--proxy").Output()
+ if err != nil {
+ return nil, nil, err
+ }
+ values, exceptions := parseSystemProxy(string(b))
+ return values, exceptions, nil
+}
+
+func parseSystemProxy(text string) (map[string]string, []string) {
+ values := map[string]string{}
+ var exceptions []string
+ depth := 0
+ inExceptions := false
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasSuffix(line, "{") {
+ if depth == 1 && strings.HasPrefix(line, "ExceptionsList : ") {
+ inExceptions = true
+ }
+ depth++
+ continue
+ }
+ if line == "}" {
+ if depth == 2 {
+ inExceptions = false
+ }
+ depth--
+ continue
+ }
+ key, value, ok := strings.Cut(line, " : ")
+ if !ok {
+ continue
+ }
+ if depth == 1 {
+ values[key] = value
+ }
+ if depth == 2 && inExceptions {
+ exceptions = append(exceptions, value)
+ }
+ }
+ return values, exceptions
+}
diff --git a/internal/macos/retention_darwin.go b/internal/macos/retention_darwin.go
new file mode 100644
index 0000000..b41eac2
--- /dev/null
+++ b/internal/macos/retention_darwin.go
@@ -0,0 +1,221 @@
+//go:build darwin && arm64 && cgo
+
+package macos
+
+/*
+#include
+#include
+#include
+#include
+#include
+#include
+static int daily_mkdir(int parent, const char *name) {
+ return mkdirat(parent, name, 0700) == 0 ? 0 : errno;
+}
+static int daily_move(int from, const char *src, int to, const char *dst) {
+ return renameatx_np(from, src, to, dst, RENAME_EXCL) == 0 ? 0 : errno;
+}
+static int daily_unlink(int parent, const char *name, int directory) {
+ return unlinkat(parent, name, directory ? AT_REMOVEDIR : 0) == 0 ? 0 : errno;
+}
+*/
+import "C"
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+ "unsafe"
+)
+
+func retentionName(fn func(*C.char) C.int, name string) error {
+ p := C.CString(name)
+ defer C.free(unsafe.Pointer(p))
+ if code := fn(p); code != 0 {
+ return syscall.Errno(code)
+ }
+ return nil
+}
+
+func retentionMove(from *os.File, src string, to *os.File, dst string) error {
+ a, b := C.CString(src), C.CString(dst)
+ defer C.free(unsafe.Pointer(a))
+ defer C.free(unsafe.Pointer(b))
+ if code := C.daily_move(C.int(from.Fd()), a, C.int(to.Fd()), b); code != 0 {
+ return syscall.Errno(code)
+ }
+ return nil
+}
+
+func retentionUnlink(dir *os.File, name string, directory bool) error {
+ flag := C.int(0)
+ if directory {
+ flag = 1
+ }
+ return retentionName(func(p *C.char) C.int { return C.daily_unlink(C.int(dir.Fd()), p, flag) }, name)
+}
+
+func unchangedRetained(expected, actual os.FileInfo, directory bool) bool {
+ if !os.SameFile(expected, actual) || expected.IsDir() != directory || actual.IsDir() != directory ||
+ actual.Mode()&os.ModeSymlink != 0 || !expected.ModTime().Equal(actual.ModTime()) {
+ return false
+ }
+ return directory || (plainSingleFile(actual) && expected.Size() == actual.Size())
+}
+
+// UnlinkRetained moves a selected object into a fresh private sibling directory,
+// checks its identity again, then removes it relative to held directory FDs.
+// Replaced entries and unexpected directory contents are preserved, never followed.
+func UnlinkRetained(path string, expected os.FileInfo, directory bool) (result error) {
+ if busy, gone := FileInUse(path); gone {
+ return os.ErrNotExist
+ } else if busy {
+ return fmt.Errorf("日志仍被打开,未删除: %s", path)
+ }
+ parent, err := dailyOpenDirectory(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ defer parent.Close()
+ flags := syscall.O_RDONLY
+ if directory {
+ flags |= syscall.O_DIRECTORY
+ }
+ f, err := dailyOpenAt(parent, filepath.Base(path), flags)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil || !unchangedRetained(expected, info, directory) {
+ return fmt.Errorf("日志身份或内容已变化,未删除: %s", path)
+ }
+ if directory {
+ entries, err := f.ReadDir(-1)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if !entry.Type().IsRegular() {
+ return fmt.Errorf("日志目录含子目录或链接,未删除: %s", path)
+ }
+ if busy, gone := FileInUse(filepath.Join(path, entry.Name())); busy || gone {
+ return fmt.Errorf("日志目录仍在使用或内容变化,未删除: %s", path)
+ }
+ }
+ }
+ var random [12]byte
+ if _, err := rand.Read(random[:]); err != nil {
+ return err
+ }
+ trashName := ".modavd-retention-" + hex.EncodeToString(random[:])
+ if err := retentionName(func(p *C.char) C.int { return C.daily_mkdir(C.int(parent.Fd()), p) }, trashName); err != nil {
+ return err
+ }
+ trash, err := dailyOpenAt(parent, trashName, syscall.O_RDONLY|syscall.O_DIRECTORY)
+ if err != nil {
+ _ = retentionUnlink(parent, trashName, true)
+ return err
+ }
+ defer trash.Close()
+ defer retentionUnlink(parent, trashName, true) // only removes an empty directory
+ if err := retentionMove(parent, filepath.Base(path), trash, "item"); err != nil {
+ return err
+ }
+ restore := true
+ defer func() {
+ if restore {
+ // Never overwrite an entry another writer created meanwhile. If it
+ // exists, the moved object remains intact in the private directory.
+ if err := retentionMove(trash, "item", parent, filepath.Base(path)); err != nil {
+ result = errors.Join(result, fmt.Errorf("日志保留在 %s: %w", filepath.Join(trash.Name(), "item"), err))
+ }
+ }
+ }()
+ moved, err := dailyOpenAt(trash, "item", flags)
+ if err != nil {
+ return err
+ }
+ defer moved.Close()
+ actual, err := moved.Stat()
+ if err != nil || !unchangedRetained(expected, actual, directory) {
+ return fmt.Errorf("日志移动时身份或内容变化,已保留: %s", path)
+ }
+ if !directory {
+ // The original name is no longer available to new writers. Release our
+ // own handles before checking for writers that opened it before the move.
+ f.Close()
+ moved.Close()
+ if busy, gone := fileInUse(filepath.Join(trash.Name(), "item"), path); busy || gone {
+ return fmt.Errorf("日志移动后仍被打开或已变化,已保留: %s", path)
+ }
+ }
+ if directory {
+ entries, err := moved.ReadDir(-1)
+ if err != nil {
+ return err
+ }
+ // Inspect the whole flat snapshot before deleting any child.
+ selected := make(map[string]os.FileInfo, len(entries))
+ for _, entry := range entries {
+ if !entry.Type().IsRegular() {
+ return fmt.Errorf("日志目录含子目录或链接,已保留: %s", path)
+ }
+ child, err := dailyOpenAt(moved, entry.Name(), syscall.O_RDONLY)
+ if err != nil {
+ return err
+ }
+ childInfo, statErr := child.Stat()
+ child.Close()
+ if statErr != nil || !plainSingleFile(childInfo) {
+ return fmt.Errorf("日志目录含非独立普通文件,已保留: %s", path)
+ }
+ selected[entry.Name()] = childInfo
+ }
+ for _, entry := range entries {
+ if err := unlinkRetainedChild(moved, entry.Name(), selected[entry.Name()], trash, filepath.Join(path, entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+ }
+ if err := retentionUnlink(trash, "item", directory); err != nil {
+ return err
+ }
+ restore = false
+ return nil
+}
+
+func unlinkRetainedChild(parent *os.File, name string, selected os.FileInfo, trash *os.File, writerPath string) (result error) {
+ if err := retentionMove(parent, name, trash, "child"); err != nil {
+ return err
+ }
+ restore := true
+ defer func() {
+ if restore {
+ if err := retentionMove(trash, "child", parent, name); err != nil {
+ result = errors.Join(result, fmt.Errorf("日志子文件保留在 %s: %w", filepath.Join(trash.Name(), "child"), err))
+ }
+ }
+ }()
+ child, err := dailyOpenAt(trash, "child", syscall.O_RDONLY)
+ if err != nil {
+ return err
+ }
+ info, err := child.Stat()
+ child.Close()
+ if err != nil || !unchangedRetained(selected, info, false) {
+ return fmt.Errorf("日志子文件身份或内容变化,未删除: %s", name)
+ }
+ if busy, gone := fileInUse(filepath.Join(trash.Name(), "child"), writerPath); busy || gone {
+ return fmt.Errorf("日志子文件仍被打开或已变化,未删除: %s", name)
+ }
+ if err := retentionUnlink(trash, "child", false); err != nil {
+ return err
+ }
+ restore = false
+ return nil
+}
diff --git a/internal/root/platform.go b/internal/root/platform.go
index 5467eee..d15049d 100644
--- a/internal/root/platform.go
+++ b/internal/root/platform.go
@@ -46,6 +46,17 @@ func (p Platform) Supported() bool { return p.Emulator != "" }
// Windows-only implementation.
func (p Platform) Windows() bool { return p.QemuDir == "windows-x86_64" && p.MirrorOS == "win" }
+// CLI is the root-local console executable used by background workers.
+func (p Platform) CLI() string {
+ if !p.Supported() {
+ return ""
+ }
+ if p.Windows() {
+ return "modavd.exe"
+ }
+ return "modavd"
+}
+
func CurrentPlatform() Platform {
switch {
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
diff --git a/internal/root/root.go b/internal/root/root.go
index e90e05f..97b98a1 100644
--- a/internal/root/root.go
+++ b/internal/root/root.go
@@ -342,7 +342,11 @@ func (r *Root) QemuHeadlessExe() string {
// QemuImgExe is the SDK's qemu-img (disk info / compact), errs.NotReady when it is not installed.
func (r *Root) QemuImgExe() (string, error) {
- exe := filepath.Join(r.RuntimeSDK(), "emulator", "qemu-img.exe")
+ name := "qemu-img"
+ if CurrentPlatform().Windows() {
+ name += ".exe"
+ }
+ exe := filepath.Join(r.RuntimeSDK(), "emulator", name)
if fi, err := os.Stat(exe); err != nil || fi.IsDir() {
return exe, errs.Newf(errs.NotReady, "找不到 qemu-img: %s", exe).
WithAdvice("魔改 AVD 的模拟器组件不完整,请更新组件后再试")
@@ -844,6 +848,8 @@ func (r *Root) Components() []ComponentStatus {
emulator.Build = r.VersionText()
b, ok := r.ModBuild()
emulator.Installed = ok && b >= MinModBuild
+ } else if r.EmulatorPresent() && OfficialEmulatorVersionSupported(emulator.Version) {
+ emulator.Installed = true
}
launcher := ComponentStatus{ID: "launcher", Name: LauncherName, Version: LauncherVersion, Optional: true}
if fi, err := os.Stat(r.LauncherAPK()); err == nil && fi.Size() == LauncherAPKSize {
@@ -893,6 +899,12 @@ func (r *Root) EmulatorPresent() bool {
return allFiles(filepath.Join(r.RuntimeSDK(), "emulator"), keyFiles["emulator"])
}
+// OfficialEmulatorVersionSupported is the verified macOS SDK fallback until a
+// native ModAVD package is distributed. Windows keeps requiring the ModAVD build.
+func OfficialEmulatorVersionSupported(version string) bool {
+ return CurrentPlatform().MirrorOS == "macos" && (version == "37.2.13" || version == "37.2.13.0")
+}
+
// EmulatorSelfBuilt: present and qemu-system-x86_64-headless.exe contains SelfBuiltMark.
func (r *Root) EmulatorSelfBuilt() bool {
return r.EmulatorPresent() && HasSelfBuiltMark(r.QemuHeadlessExe())