diff --git a/README.en.md b/README.en.md index 4c69ddf..f742f91 100644 --- a/README.en.md +++ b/README.en.md @@ -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. Memory is not checked (the page file makes up for missing physical memory); `--memory` is still accepted but does nothing. When the emulator really cannot get the memory, `start` fails with `insufficient_memory` (exit code 10) | | `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). Besides the emulator logs it takes the logs of the manager, the boot watch and the private adb servers (`modavd-ui.log`, `modavd-bootwatch.log`, `adb-server.log` and their `.1`, the last 5 MB of each), every adb.exe process and the adb server log (the last 2 MB of `%TEMP%\adb.log`), and the TCP ports Windows reserves (netsh), saying whether a port this root uses is among them. 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 | @@ -86,6 +86,8 @@ Apple Silicon macOS supports `report-config` and `collect-info`, including hardw - 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. diff --git a/README.md b/README.md index 33d9167..5ff7a4b 100644 --- a/README.md +++ b/README.md @@ -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]` | 开机前的电脑检查。不查内存(页面文件会补上物理内存的不足),`--memory` 留着兼容、不再起作用;真分不到内存时 `start` 报 `insufficient_memory`(退出码 10) | | `report-config [--no-clipboard]` | 复制电脑配置和开着实例里的游戏信息(只读) | | `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | 收集问题信息并打包(只读)。除模拟器日志外,还收管理器、开机守护和私有 adb server 的日志(`modavd-ui.log`、`modavd-bootwatch.log`、`adb-server.log` 及其 `.1`,各取最后 5 MB)、所有 adb.exe 进程和 adb server 日志(`%TEMP%\adb.log` 最后 2 MB)、Windows 保留的 TCP 端口(netsh),并指出本目录要用的端口有没有落在里面;ZIP 完成前的 Ctrl+C 返回 `canceled`,删除未完成 ZIP 并清理暂存目录;`--keep-stage` 则保留暂存目录并在错误中给出路径 | @@ -92,6 +92,8 @@ Apple Silicon macOS 支持 `report-config` 和 `collect-info`:读取硬件、m 自动回收日志里的秒数是 adb/su 命令往返耗时。客体是否完成或失败,需要另看客体信号, 不能用暂时稳定的 discard 计数判定。 +Apple Silicon macOS 使用 SDK 中原生的 `qemu-img`。磁盘占用按宿主已分配块计算,排除稀疏文件空洞;APFS 克隆可能共享数据块,整理前后文件占用的差值不保证等于整个卷增加的可用空间。普通日志清理会检查实际打开的文件,保留正在使用、身份已变化、带链接或意外子目录的候选;macOS 的诊断模式开关仍未开放。 + **arm64 执行桥**: - 只对内置名单里的游戏生效,其余应用照旧走原来的转译层。桥装在实例自己的 system / vendor 覆盖层里,要 0.36 起的模拟器。 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/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/disk.go b/internal/emu/disk.go index 4e8511f..440fbc5 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/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/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/guest/guest.go b/internal/guest/guest.go index e872e30..8317cd8 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,48 @@ 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 { - return nil, ErrNoProxyDaemon + if abi == "x86_64" { + return nil, ErrNoProxyDaemon + } + return nil, fmt.Errorf("此构建未附带 ARM64 客体代理程序 %s", name) + } + if len(b) > 64<<20 { + return nil, errors.New("客体代理程序过大") + } + exe, err := elf.NewFile(bytes.NewReader(b)) + if err != nil { + 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..da95729 100644 --- a/internal/guest/proxyd/README.md +++ b/internal/guest/proxyd/README.md @@ -2,3 +2,8 @@ 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 embed `modavd-proxyd-arm64` from the same `cmd/modavd-proxyd` 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/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 58281e4..a2c6cfb 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 } diff --git a/internal/host/windows.go b/internal/host/windows.go index b1b7a80..548f2b1 100644 --- a/internal/host/windows.go +++ b/internal/host/windows.go @@ -67,6 +67,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 Protect(data, entropy []byte) ([]byte, error) { return win.Protect(data, entropy) } 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/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 c0a4047..90daa41 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("ModAVD 的模拟器组件不完整,请更新组件后再试")