Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a070a41
feat(macos): define ARM64 guest profile and emulator paths
DeathofBrain Nov 5, 2026
55f5530
chore: remove test changes from macOS profile PR
DeathofBrain Nov 5, 2026
47e52c2
Merge upstream main and retain 1080p resolution defaults
DeathofBrain Nov 5, 2026
5ad74e7
feat: implement Darwin process runtime primitives
DeathofBrain Nov 5, 2026
1432755
Merge remote-tracking branch 'origin/main' into codex/darwin-process-…
DeathofBrain Nov 5, 2026
fcefa74
refactor: separate host routing from platform backends
DeathofBrain Nov 5, 2026
41fa471
Merge main into codex/darwin-process-runtime and resolve host conflicts
DeathofBrain Nov 5, 2026
a24cd4c
Merge latest main and retain portable host test imports
DeathofBrain Nov 5, 2026
f829e44
Merge remote-tracking branch 'origin/main' into codex/macos-arm64-pro…
DeathofBrain Nov 5, 2026
acd96b5
test: sync existing platform expectations for ARM64 profiles
DeathofBrain Nov 5, 2026
36197b7
Merge remote-tracking branch 'origin/main' into codex/macos-arm64-pro…
DeathofBrain Nov 5, 2026
e1ddfd2
Merge latest main and retain host routing for compact and logcat
DeathofBrain Nov 5, 2026
81ef060
Merge implemented macOS stages 1 and 2
DeathofBrain Nov 5, 2026
5adf4c7
feat: add macOS host and ARM64 boot preflight checks
DeathofBrain Nov 5, 2026
6f92a12
feat: fetch pinned Darwin ARM64 SDK components
DeathofBrain Nov 5, 2026
d22e58a
Merge remote-tracking branch 'origin/main' into codex/macos-1-api35-r…
DeathofBrain Nov 6, 2026
22e3dbf
fix: select Android API level from the host platform table
DeathofBrain Nov 6, 2026
71bb88c
Merge current main and preserve host routing for new upstream calls
DeathofBrain Nov 6, 2026
489f193
Merge branch 'codex/macos-1-api35-review' into codex/macos-review-base
DeathofBrain Nov 6, 2026
d8e8a46
Merge branch 'codex/macos-review-base' into codex/macos-4-review
DeathofBrain Nov 6, 2026
dd5463c
fix: check the platform API level in Darwin image metadata
DeathofBrain Nov 6, 2026
f32641c
Merge branch 'codex/macos-4-review' into codex/macos-6-review
DeathofBrain Nov 6, 2026
5c9f495
fix: pin Darwin system image to API 35 revision 9
DeathofBrain Nov 6, 2026
e7baf75
fix: identify Darwin SDK busy paths by directory identity
DeathofBrain Nov 6, 2026
4531acb
fix: use named macOS system image constant
DeathofBrain Nov 6, 2026
8c61f79
Merge branch 'codex/macos-1-api35-review' into codex/macos-review-base
DeathofBrain Nov 6, 2026
b8769d4
Merge branch 'codex/macos-review-base' into codex/macos-4-review
DeathofBrain Nov 6, 2026
1f7835f
Merge branch 'codex/macos-4-review' into codex/macos-6-review
DeathofBrain Nov 6, 2026
702f23b
Merge main into macOS preflight checks
DeathofBrain Nov 6, 2026
3d6c561
Merge commit '702f23b1bb60c944dc8e80a405642b395a5539cd' into codex/ma…
DeathofBrain Nov 6, 2026
7a315db
fix: align macOS component consent with manager download sources
DeathofBrain Nov 6, 2026
ea308bf
Merge main and preserve platform-specific component installation
DeathofBrain Nov 6, 2026
447c956
feat: prepare Google Play ARM64 components on macOS
DeathofBrain Nov 6, 2026
8c120e1
feat: open the macOS manager in browser windows
DeathofBrain Nov 6, 2026
9dadff0
Merge latest main into macOS manager windows
DeathofBrain Nov 7, 2026
4580115
fix(ui): check memory for the selected instance
DeathofBrain Nov 8, 2026
ddb46e7
fix(ui): preserve forced refresh across concurrent checks
DeathofBrain Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ On 2026-11-03, Claude Code 2.1.317 (the command-line version) was connected to m

## The manager ModAVD.exe

On Apple Silicon macOS, run the `ModAVD` manager. A `ModAVD.app` placed in the package root finds that root automatically; `--root` can select it explicitly. The page opens in the default browser. Pass `--chrome-window` for a separate Chrome app window, falling back to the default browser when Chrome is unavailable. State and the Chrome profile live under `~/Library/Application Support/ModAVD`; folder buttons open Finder. Launching again on the same root only reopens the page. Closing the page follows the existing idle and running-job exit rules. Instance operations remain subject to their platform support limits.

Double-click `ModAVD.exe` in the package root: it starts a local server that listens on 127.0.0.1 only, then opens a window in Edge's app mode (`msedge --app`, built into Win10 / 11); if Edge is not found, it uses the default browser. The pages are HTML / CSS / JS embedded in the exe and need no network; it goes online only when you click "Check for updates" (`检查更新`) in "Settings" (`设置`).

- Features: instance cards (with a screen thumbnail when the instance is online), start and shut down (with progress; it always starts with the window, headless starts are for the CLI only; there is no restart and no showing / hiding of windows), create, settings, delete, computer check, activity panel, screen viewer (a click is a tap; Back / Home / Recent tasks), logs, install APK, open folder; light / dark / follow system.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd

## 管理器 ModAVD.exe

Apple Silicon macOS 可运行 `ModAVD` 管理器;`ModAVD.app` 放在包根目录时会自动定位根目录,也可以用 `--root` 指定。默认在系统浏览器打开页面;加 `--chrome-window` 使用独立 Chrome 应用窗口,Chrome 不可用时回到默认浏览器。状态和 Chrome 配置保存在 `~/Library/Application Support/ModAVD`,目录按钮用 Finder 打开。同一根目录再次启动只重开页面;关闭页面后按原有空闲和任务规则退出。实例功能仍受各项平台支持范围限制。

双击包根的 `ModAVD.exe`:起一个只听 127.0.0.1 的本地服务,再用 Edge 的应用模式开一个窗口(`msedge --app`,Win10 / 11 自带);找不到 Edge 时用默认浏览器。页面是内嵌在 exe 里的 HTML / CSS / JS,不需要联网;只有在「设置」里点「检查更新」时才联网。

- 功能:实例卡片(在线时有画面缩略图)、开关机(带进度;开机一律带窗口,无头开机只走命令行;不提供重启,也不提供显示 / 隐藏窗口)、新建、设置、删除、电脑检查、活动面板、画面查看(点击即点按,返回 / 主页 / 最近任务)、日志、安装 APK、打开目录;浅色 / 深色 / 跟随系统。
Expand Down
7 changes: 7 additions & 0 deletions cmd/modavd-gui/identity_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build darwin && arm64

package main

import "modavd/internal/host"

func managerRootIdentity(path string) string { return host.ManagerRootIdentity(path) }
10 changes: 10 additions & 0 deletions cmd/modavd-gui/identity_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !darwin || !arm64

package main

import (
"path/filepath"
"strings"
)

func managerRootIdentity(path string) string { return strings.ToLower(filepath.Clean(path)) }
46 changes: 32 additions & 14 deletions cmd/modavd-gui/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ModAVD.exe: the desktop manager of one ModAVD root. It runs the local server of package ui and opens it in an Edge
// app window; closing the window ends it (after running jobs are done). Double-clicking it again while it runs only
// reopens the window.
// ModAVD is the desktop manager of one root. It runs ui's local server and opens
// an Edge app window on Windows or the default browser on macOS (optionally Chrome
// app mode). Closing the page ends it after running jobs finish. A second launch
// on the same root reopens the page.
//
// ModAVD.exe [--root <dir>] [--port N] [--no-window] [--dev]
// ModAVD.exe __version (hidden: version JSON on stdout, see versionArg)
Expand Down Expand Up @@ -61,7 +62,12 @@ func main() {
os.Exit(run(os.Args[1:]))
}

const usage = "用法:ModAVD.exe [--root 根目录] [--port 端口] [--no-window] [--dev]"
var usage = func() string {
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
return "用法:ModAVD [--root 根目录] [--port 端口] [--no-window] [--chrome-window] [--dev]"
}
return "用法:ModAVD.exe [--root 根目录] [--port 端口] [--no-window] [--dev]"
}()

// flagValueNames are the placeholders the usage line gives the options that take a value.
var flagValueNames = map[string]string{"root": "根目录", "port": "端口"}
Expand Down Expand Up @@ -113,6 +119,10 @@ func run(args []string) (code int) {
rootFlag := fs.String("root", "", "魔改 AVD 根目录")
port := fs.Int("port", 0, "监听端口(0 = 随机)")
noWindow := fs.Bool("no-window", false, "不打开窗口")
var chromeWindow bool
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
fs.BoolVar(&chromeWindow, "chrome-window", false, "用独立 Chrome 应用窗口打开(默认使用系统浏览器)")
}
dev := fs.Bool("dev", false, "开发模式:提供 /dev/ 的模拟后端页面,没有窗口也不自动退出")
if err := fs.Parse(args); err != nil {
// --help / -h / -help ask for the usage: an information box and exit code 0, not an error (ModAVD/ModAVD#115).
Expand Down Expand Up @@ -152,18 +162,19 @@ func run(args []string) (code int) {
return 1
}
if inst == nil {
return reopen(r, *noWindow, log)
return reopen(r, *noWindow, chromeWindow, log)
}
defer inst.release()

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
opt := ui.Options{
Root: r,
Log: log,
Addr: fmt.Sprintf("127.0.0.1:%d", *port),
OpenWindow: !*noWindow,
Dev: *dev,
Root: r,
Log: log,
Addr: fmt.Sprintf("127.0.0.1:%d", *port),
OpenWindow: !*noWindow,
ChromeWindow: chromeWindow,
Dev: *dev,
Ready: func(baseURL, token string) {
if err := inst.writeState(baseURL, token); err != nil {
log.Warnf("写不了管理器状态文件(再次打开时找不到这个管理器): %v", err)
Expand All @@ -185,19 +196,26 @@ func run(args []string) (code int) {
}

// reopen is the second launch on a root: the running manager gets a new window.
func reopen(r *root.Root, noWindow bool, log *logx.Logger) int {
func reopen(r *root.Root, noWindow, chromeWindow bool, log *logx.Logger) int {
st, err := readStateWait(r, 5*time.Second)
if err != nil {
log.Errorf("已有管理器在运行,但读不到它的地址: %v", err)
fatal("这个根目录的 ModAVD 管理器已经在运行,但找不到它的地址。\n\n" +
"请关掉已经打开的管理器窗口,等十几秒后再打开;还不行就在任务管理器里结束 ModAVD.exe。\n\n根目录:" + r.Dir)
advice := "请关掉已经打开的管理器窗口,等十几秒后再打开;还不行就在任务管理器里结束 ModAVD.exe。"
if runtime.GOOS == "darwin" {
advice = "请关掉已经打开的管理器页面,等一分钟后再打开;还不行就在活动监视器里结束 ModAVD 管理器。"
}
fatal("这个根目录的 ModAVD 管理器已经在运行,但找不到它的地址。\n\n" + advice + "\n\n根目录:" + r.Dir)
return 1
}
if noWindow {
return 0
}
log.Infof("管理器已在运行(%s),重新打开窗口", st.URL)
if _, err := ui.LaunchWindow(st.URL + "?t=" + st.Token); err != nil {
launch := ui.LaunchWindow
if chromeWindow {
launch = ui.LaunchChromeWindow
}
if _, err := launch(st.URL + "?t=" + st.Token); err != nil {
log.Errorf("打不开管理器窗口: %v", err)
fatal("打不开管理器窗口:" + err.Error() + "\n\n可以在浏览器里打开 " + st.URL)
return 1
Expand Down
6 changes: 3 additions & 3 deletions cmd/modavd-gui/single.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import (
"modavd/internal/ui"
)

// One manager per root: a lock file plus a state file under %LOCALAPPDATA%\ModAVD, both named after a hash of the
// One manager per root: a lock file plus a state file under ui.AppDataDir, both named after a hash of the
// root path. The lock is held for the manager's whole life and dies with its process, so a crash never blocks the
// next start; the state file tells a second launch where the running manager listens.

// instanceKey is "ui-<16 hex>" for the root (case-insensitive, like Windows paths).
// instanceKey is "ui-<16 hex>" for the root (Windows path or macOS directory identity).
func instanceKey(r *root.Root) string {
sum := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(r.Dir))))
sum := sha256.Sum256([]byte(managerRootIdentity(r.Dir)))
return "ui-" + hex.EncodeToString(sum[:8])
}

Expand Down
14 changes: 14 additions & 0 deletions internal/emu/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ func (m *Manager) Check(ctx context.Context, opt CheckOptions) (*CheckResult, er
return res, nil
}

// RootReady reports whether the checks shared by every instance passed. Memory
// depends on the selected instance and is checked again before every launch.
func (r *CheckResult) RootReady() bool {
for k, item := range r.Items {
if item.ID == "memory" {
continue
}
if _, failed := firstBlockingFailure(r.Items[k : k+1]); failed {
return false
}
}
return true
}

// ComponentStatuses is root's component list in the JSON shape of check's "components" (every field always present),
// which `components status` returns too. Never nil.
func ComponentStatuses(comps []root.ComponentStatus) []ComponentStatus {
Expand Down
17 changes: 17 additions & 0 deletions internal/host/desktop_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build darwin && arm64

package host

import "modavd/internal/macos"

type ManagerWindow = macos.ManagerWindow

func ManagerDataDir() string { return macos.ManagerDataDir() }
func ManagerRootIdentity(path string) string { return macos.ManagerRootIdentity(path) }
func ManagerBundleRoot(exe string) string { return macos.ManagerBundleRoot(exe) }
func LaunchManagerWindow(url string, chrome bool) (*ManagerWindow, error) {
return macos.LaunchManagerWindow(url, chrome)
}
func OpenDesktop(target string) error { return macos.OpenDesktop(target) }
func MessageBox(message string) error { return macos.ShowManagerMessage(message, false) }
func InfoBox(message string) error { return macos.ShowManagerMessage(message, true) }
8 changes: 8 additions & 0 deletions internal/host/message_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build !windows && (!darwin || !arm64)

package host

import "errors"

func MessageBox(string) error { return errors.New("当前平台暂不支持消息框") }
func InfoBox(string) error { return errors.New("当前平台暂不支持消息框") }
6 changes: 0 additions & 6 deletions internal/host/unsupported_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
package host

import (
"errors"
"os"
"os/exec"

Expand Down Expand Up @@ -59,9 +58,6 @@ func SetEmulatorWindowsVisible(uint32, bool) (int, error) { return 0, errUnsuppo
// GetConsoleWindow calls the Windows GetConsoleWindow API.
func GetConsoleWindow() uintptr { return 0 }

// MessageBox calls MessageBoxW from Windows user32.
func MessageBox(string) error { return errors.New("当前平台暂不支持消息框") }

// OpenStableRead requires Windows file-sharing semantics.
func OpenStableRead(string) (*os.File, error) { return nil, errUnsupported }

Expand All @@ -70,5 +66,3 @@ 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("当前平台暂不支持消息框") }
115 changes: 115 additions & 0 deletions internal/macos/desktop_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//go:build darwin && arm64

package macos

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)

// ManagerWindow describes only the browser process started for the manager.
// A default-browser launch has no owned process and is tracked by page activity.
type ManagerWindow struct {
App bool
Started time.Time
Done <-chan struct{}
}

// ManagerDataDir is this user's manager state and optional Chrome profile.
func ManagerDataDir() string {
base, err := os.UserConfigDir()
if err != nil || base == "" {
// Keep a failed home-directory lookup separate from other local users.
return filepath.Join(os.TempDir(), fmt.Sprintf("ModAVD-%d", os.Getuid()))
}
return filepath.Join(base, "ModAVD")
}

// ManagerRootIdentity identifies aliases of one root on both case-sensitive
// and case-insensitive volumes, without conflating distinct case-sensitive roots.
func ManagerRootIdentity(path string) string {
if info, err := os.Stat(path); err == nil {
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
}
}
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
return filepath.Clean(path)
}

// ManagerBundleRoot recognizes <root>/*.app/Contents/MacOS/<executable>.
// It deliberately does not search arbitrary ancestors above an executable.
func ManagerBundleRoot(exe string) string {
dir := filepath.Dir(filepath.Clean(exe))
contents := filepath.Dir(dir)
bundle := filepath.Dir(contents)
if filepath.Base(dir) != "MacOS" || filepath.Base(contents) != "Contents" ||
!strings.EqualFold(filepath.Ext(bundle), ".app") {
return ""
}
return filepath.Dir(bundle)
}

// LaunchManagerWindow uses the default browser unless Chrome was explicitly
// requested. Chrome always gets a separate profile with sync and extensions off.
func LaunchManagerWindow(url string, chrome bool) (*ManagerWindow, error) {
if chrome {
if exe := findChrome(); exe != "" {
profile := filepath.Join(ManagerDataDir(), "ui-chrome")
if err := os.MkdirAll(profile, 0o700); err == nil {
cmd := exec.Command(exe, chromeArgs(url, profile)...)
if err := cmd.Start(); err == nil {
done := make(chan struct{})
go func() {
_ = cmd.Wait()
close(done)
}()
return &ManagerWindow{App: true, Started: time.Now(), Done: done}, nil
}
}
}
}
if err := OpenDesktop(url); err != nil {
return nil, fmt.Errorf("打不开默认浏览器: %w", err)
}
return &ManagerWindow{Started: time.Now()}, nil
}

func chromeArgs(url, profile string) []string {
return []string{"--app=" + url, "--user-data-dir=" + profile, "--no-first-run", "--no-default-browser-check",
"--disable-sync", "--disable-extensions", "--window-size=1180,760"}
}

func findChrome() string {
paths := []string{"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"))
}
for _, path := range paths {
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 {
return path
}
}
return ""
}

// OpenDesktop asks Launch Services to open a URL or a folder, with no shell.
func OpenDesktop(target string) error { return exec.Command("/usr/bin/open", "--", target).Run() }

// ShowManagerMessage keeps the message in argv instead of interpolating it into
// AppleScript, so paths and error text cannot become executable script.
func ShowManagerMessage(message string, info bool) error {
icon := "stop"
if info {
icon = "note"
}
script := "on run argv\ndisplay dialog (item 1 of argv) with title \"ModAVD\" buttons {\"好\"} default button 1 with icon " + icon + "\nend run"
return exec.Command("/usr/bin/osascript", "-e", script, "--", message).Run()
}
18 changes: 18 additions & 0 deletions internal/root/discovery_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build darwin && arm64

package root

import (
"path/filepath"

"modavd/internal/host"
)

func rootCandidates(exe string) []string {
dir := filepath.Dir(exe)
candidates := []string{dir, filepath.Dir(dir)}
if bundleRoot := host.ManagerBundleRoot(exe); bundleRoot != "" {
candidates = append(candidates, bundleRoot)
}
return candidates
}
10 changes: 10 additions & 0 deletions internal/root/discovery_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !darwin || !arm64

package root

import "path/filepath"

func rootCandidates(exe string) []string {
dir := filepath.Dir(exe)
return []string{dir, filepath.Dir(dir)}
}
16 changes: 10 additions & 6 deletions internal/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ type Root struct {

// Resolve picks the root: the --root flag when given, else env MODAVD_ROOT, else the directory this executable
// sits in or its parent, whichever looks like a root (has sdk\ or mas-avd.json; the package puts modavd.exe in
// <root>\tools). The directory must exist. Error code: NotFound / Usage.
// <root>\tools). A macOS .app also checks the directory containing the bundle.
// The directory must exist. Error code: NotFound / Usage.
func Resolve(flag string) (*Root, error) {
exe, _ := os.Executable()
return resolve(flag, os.Getenv(EnvRoot), exe)
Expand All @@ -260,16 +261,19 @@ func resolve(flag, env, exe string) (*Root, error) {
return openDir(s, "环境变量 "+EnvRoot+" 指定的")
}
if exe != "" {
dir := filepath.Dir(exe)
for _, cand := range []string{dir, filepath.Dir(dir)} {
for _, cand := range rootCandidates(exe) {
if looksLikeRoot(cand) {
return openDir(cand, "")
}
}
}
return nil, errs.New(errs.Usage, "找不到魔改 AVD 根目录").WithAdvice(
"用 --root 指定根目录(里面有 sdk 文件夹和 mas-avd.json),或设置环境变量 " + EnvRoot +
";modavd.exe 放在根目录或根目录的 tools 文件夹里时会自动找到")
advice := "用 --root 指定根目录(里面有 sdk 文件夹和 mas-avd.json),或设置环境变量 " + EnvRoot
if CurrentPlatform().MirrorOS == "macos" {
advice += ";ModAVD.app 放在根目录,或 modavd 放在根目录或 tools 文件夹里时会自动找到"
} else {
advice += ";modavd.exe 放在根目录或根目录的 tools 文件夹里时会自动找到"
}
return nil, errs.New(errs.Usage, "找不到魔改 AVD 根目录").WithAdvice(advice)
}

// cleanArg drops surrounding blanks and quotes: `--root "D:\ModAVD\"` reaches us as `D:\ModAVD"` (the backslash
Expand Down
Loading
Loading