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/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/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/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/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/start.go b/internal/emu/start.go index f9b0a2d..9a5d011 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 Vulkan fix is 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 8079930..36a21cd 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/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/unsupported_other.go b/internal/host/unsupported_other.go index 75f4987..1e3cfeb 100644 --- a/internal/host/unsupported_other.go +++ b/internal/host/unsupported_other.go @@ -68,7 +68,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/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/root/root.go b/internal/root/root.go index e90e05f..0c4b5f3 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -844,6 +844,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 +895,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())