diff --git a/README.en.md b/README.en.md
index c9e19b5..eccf2db 100644
--- a/README.en.md
+++ b/README.en.md
@@ -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.
diff --git a/README.md b/README.md
index ff7be03..e7783db 100644
--- a/README.md
+++ b/README.md
@@ -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、打开目录;浅色 / 深色 / 跟随系统。
diff --git a/cmd/modavd-gui/identity_darwin.go b/cmd/modavd-gui/identity_darwin.go
new file mode 100644
index 0000000..7348463
--- /dev/null
+++ b/cmd/modavd-gui/identity_darwin.go
@@ -0,0 +1,7 @@
+//go:build darwin && arm64
+
+package main
+
+import "modavd/internal/host"
+
+func managerRootIdentity(path string) string { return host.ManagerRootIdentity(path) }
diff --git a/cmd/modavd-gui/identity_other.go b/cmd/modavd-gui/identity_other.go
new file mode 100644
index 0000000..661d08b
--- /dev/null
+++ b/cmd/modavd-gui/identity_other.go
@@ -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)) }
diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go
index 68f2a2d..9a2ce3b 100644
--- a/cmd/modavd-gui/main.go
+++ b/cmd/modavd-gui/main.go
@@ -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
] [--port N] [--no-window] [--dev]
// ModAVD.exe __version (hidden: version JSON on stdout, see versionArg)
@@ -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": "端口"}
@@ -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).
@@ -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)
@@ -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
diff --git a/cmd/modavd-gui/single.go b/cmd/modavd-gui/single.go
index 0385e8b..1b95d02 100644
--- a/cmd/modavd-gui/single.go
+++ b/cmd/modavd-gui/single.go
@@ -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])
}
diff --git a/internal/emu/check.go b/internal/emu/check.go
index 97bb9ef..e8fdd6b 100644
--- a/internal/emu/check.go
+++ b/internal/emu/check.go
@@ -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 {
diff --git a/internal/host/desktop_darwin.go b/internal/host/desktop_darwin.go
new file mode 100644
index 0000000..548a879
--- /dev/null
+++ b/internal/host/desktop_darwin.go
@@ -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) }
diff --git a/internal/host/message_other.go b/internal/host/message_other.go
new file mode 100644
index 0000000..476bdb2
--- /dev/null
+++ b/internal/host/message_other.go
@@ -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("当前平台暂不支持消息框") }
diff --git a/internal/host/unsupported_other.go b/internal/host/unsupported_other.go
index 75f4987..beff886 100644
--- a/internal/host/unsupported_other.go
+++ b/internal/host/unsupported_other.go
@@ -3,7 +3,6 @@
package host
import (
- "errors"
"os"
"os/exec"
@@ -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 }
@@ -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("当前平台暂不支持消息框") }
diff --git a/internal/macos/desktop_darwin.go b/internal/macos/desktop_darwin.go
new file mode 100644
index 0000000..1c55843
--- /dev/null
+++ b/internal/macos/desktop_darwin.go
@@ -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 /*.app/Contents/MacOS/.
+// 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()
+}
diff --git a/internal/root/discovery_darwin.go b/internal/root/discovery_darwin.go
new file mode 100644
index 0000000..01f7e6a
--- /dev/null
+++ b/internal/root/discovery_darwin.go
@@ -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
+}
diff --git a/internal/root/discovery_other.go b/internal/root/discovery_other.go
new file mode 100644
index 0000000..f6bed0a
--- /dev/null
+++ b/internal/root/discovery_other.go
@@ -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)}
+}
diff --git a/internal/root/root.go b/internal/root/root.go
index e90e05f..8787320 100644
--- a/internal/root/root.go
+++ b/internal/root/root.go
@@ -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
-// \tools). The directory must exist. Error code: NotFound / Usage.
+// \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)
@@ -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
diff --git a/internal/ui/API.md b/internal/ui/API.md
index b84709e..127473f 100644
--- a/internal/ui/API.md
+++ b/internal/ui/API.md
@@ -66,7 +66,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
| POST | `/api/components/import-playstore` | `multipart/form-data`,文件字段 `file`,每个文件一个(1–8 个):Google 官方的 Google Play 版镜像压缩包 `x86_64-34_r14.zip`,或者那五个文件本身(文件名都不限) | 202 `Job`(`kind` 是 `playstore-import`,根任务,`index` 为 `-1`):「使用我自己的文件」。不下载、不问许可协议。服务端把上传存到临时文件夹,任务结束删掉;任务按大小认出每个文件,压缩包核对 Google 仓库清单的大小和 SHA-1,五个文件各自核对写死的大小和 SHA-256;结果是 `PlayStoreImportResult`。上传本身不对时立即回 400:不是 multipart 是 `usage`,没有 `file` 字段是 `usage`,空文件、超过 8 个、单个比压缩包还大是 `invalid_argument`。文件被拒收时任务以 `components_rejected` 失败,什么也不改,`details.reason`:`unknown_file`(大小既不是压缩包也不是五个文件之一)、`mismatch`(大小对上了、哈希对不上)、`incomplete`(只给了一部分,根目录里又没有其余的);另一个补全或导入在跑是 409 `busy`;空间不够 422 `precheck_failed` |
| POST | `/api/diag/collect` | `{out_dir?, stage_dir?, keep_stage?}` | 202 `Job`(`kind` 是 `collect-info`,根任务,`index` 为 `-1`) |
| GET | `/api/diag/report-config` | | `{"text":"…"}` |
-| GET | `/api/check?refresh=1` | | `CheckResult`(要几秒:硬件加速检查和 Vulkan 探测) |
+| GET | `/api/check?index={i}&refresh=1` | 两个参数均可省略 | `CheckResult` 的原有字段,加 `instance_index`、`memory_mb`、`root_ready`(要几秒:硬件加速检查和 Vulkan 探测) |
| GET | `/api/jobs` | | `{"jobs":[Job]}`,新的在前,最多 50 个 |
| GET | `/api/jobs/{id}` | | `Job` |
| POST | `/api/jobs/{id}/cancel` | | `Job` |
@@ -81,6 +81,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
- 截图、输入、列应用、取 GSF ID 遇到实例没开机:409,code 是 `busy`。
- `InstanceInfo` 带 `diagnostics: bool`,开启时带 `diagnostics_since`。PATCH 修改诊断时,`UpdateResult` 带 `diagnostics_cleanup: {files, bytes}`,只报实际删除量。关闭预览包含重新加入普通全根目录 1 GiB 池后需淘汰的其它普通实例旧日志;活跃文件不在预览和删除范围内。预览和提交之间现场可能变化,页面以提交结果为准。关闭部分失败时开关已保存为关,再次提交 `diagnostics: false` 会继续清理和客体恢复。
- 诊断设置成功但宿主 logcat 由外部程序采集时,`UpdateResult.warnings` 说明未接管;不结束外部进程,也不等待它切换后才返回。前端显示该提示。
+- 电脑检查的 `index` 指定检查目标:读取该实例下次开机的 `config.memory_mb`,不存在回 404,`index` 为空、重复或编号无效时回 400 `invalid_argument`。不指定时按默认 6144 MB 检查,`instance_index` 为 `null`;指定时返回该编号。`memory_mb` 总是本次检查实际使用的内存。缓存按该内存值区分;修改实例内存后不会复用旧配置结果,`refresh=1` 同时重新执行硬件加速和图形检查。`ready` 包含本次内存检查,`root_ready` 只看内存以外的通用阻断项;macOS 无法确认的通用阻断项也视为未通过。某个检查目标内存不足不应禁用其他实例,开机时仍按目标实例实际内存重新预检。
### Job
@@ -172,6 +173,7 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9,
- 0.3.0 及以前用的是 `ui-edge`,没带这些开关,可能已经登着用户的账户、同步进了收藏夹、历史、密码,所以换了新目录。
- 新版打开窗口以后删掉旧的 `ui-edge`;还有 Edge 在用它(开着旧版管理器)时跳过,下次打开再删。删不掉只记日志,不影响打开。
- 同一个根目录只开一个服务:再次双击时,把已有的窗口重新打开。
+- Apple Silicon macOS 默认用系统浏览器打开;`--chrome-window` 可改为 Chrome 应用窗口,使用 `~/Library/Application Support/ModAVD/ui-chrome` 独立配置,关闭同步和扩展。Chrome 不可用时回到默认浏览器。单实例锁和状态也在该用户的 `ModAVD` 目录,目录打开操作交给 Finder;根目录可以自动从外层的 `ModAVD.app` 定位。
- 窗口关掉(Edge 进程退出,或者 60 秒内没有任何事件流连着)以后,等正在跑的任务做完,服务再退出。
- Edge 进程在启动后 10 秒内就退出:当作把窗口交给了同一配置下已在运行的 Edge,不算关窗,之后按事件流规则判断。
- 真正关窗后,5 秒内没有事件流连着就退出(有任务先等任务做完)。
diff --git a/internal/ui/chrome.go b/internal/ui/chrome.go
new file mode 100644
index 0000000..da5aa4b
--- /dev/null
+++ b/internal/ui/chrome.go
@@ -0,0 +1,5 @@
+package ui
+
+// LaunchChromeWindow requests a separate Chrome app window on Apple Silicon
+// macOS, falling back to the default browser if Chrome cannot be started.
+func LaunchChromeWindow(url string) (*Window, error) { return launchChromeWindow(url, true) }
diff --git a/internal/ui/chrome_other.go b/internal/ui/chrome_other.go
new file mode 100644
index 0000000..40a8ece
--- /dev/null
+++ b/internal/ui/chrome_other.go
@@ -0,0 +1,9 @@
+//go:build !darwin || !arm64
+
+package ui
+
+import "modavd/internal/errs"
+
+func launchChromeWindow(string, bool) (*Window, error) {
+ return nil, errs.New(errs.Unsupported, "Chrome 管理器窗口只支持 Apple Silicon macOS")
+}
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index 446c639..d0a56f5 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -5,11 +5,13 @@ import (
"encoding/hex"
"fmt"
"net/http"
+ "net/url"
"os"
"path/filepath"
"strings"
"time"
+ "modavd/internal/avd"
"modavd/internal/buildinfo"
"modavd/internal/components"
"modavd/internal/emu"
@@ -234,7 +236,8 @@ func (s *server) respondJob(w http.ResponseWriter) func(jobView, error) {
}
func (s *server) handleOpen(w http.ResponseWriter, r *http.Request) {
- if !root.CurrentPlatform().Windows() {
+ platform := root.CurrentPlatform()
+ if !platform.Windows() && platform.MirrorOS != "macos" {
writeError(w, errs.New(errs.Unsupported, "当前平台暂不支持打开目录"))
return
}
@@ -284,7 +287,11 @@ func (s *server) handleOpen(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.openPath(dir); err != nil {
- writeError(w, errs.Wrap(errs.Internal, err, "在资源管理器里打开 "+dir+" 失败: "+err.Error()))
+ message := "在资源管理器里打开 "
+ if platform.MirrorOS == "macos" {
+ message = "在 Finder 里打开 "
+ }
+ writeError(w, errs.Wrap(errs.Internal, err, message+dir+" 失败: "+err.Error()))
return
}
writeJSON(w, http.StatusOK, struct{}{})
@@ -481,7 +488,42 @@ func (s *server) handleReportConfig(w http.ResponseWriter, r *http.Request) {
}
func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) {
- refresh := r.URL.Query().Get("refresh") == "1"
+ query, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ writeError(w, errs.New(errs.InvalidArgument, "检查参数不合法"))
+ return
+ }
+ refresh := query.Get("refresh") == "1"
+ var index *int
+ memoryMB := avd.DefaultMemoryMB
+ if values, provided := query["index"]; provided {
+ if len(values) != 1 {
+ writeError(w, errs.New(errs.InvalidArgument, "只能指定一个检查实例"))
+ return
+ }
+ i, err := parseIndex(values[0])
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ info, err := s.mgr.Info(r.Context(), i)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ index = &i
+ if info.Config.MemoryMB > 0 {
+ memoryMB = info.Config.MemoryMB
+ }
+ }
+ writeResult := func(res *emu.CheckResult) {
+ writeJSON(w, http.StatusOK, struct {
+ *emu.CheckResult
+ InstanceIndex *int `json:"instance_index"`
+ MemoryMB int `json:"memory_mb"`
+ RootReady bool `json:"root_ready"`
+ }{res, index, memoryMB, res.RootReady()})
+ }
asked := time.Now()
c := &s.check
if err := c.lock(r.Context()); err != nil {
@@ -489,26 +531,28 @@ func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
defer c.unlock()
- // a check that finished while this request waited is as fresh as one started for it
+ // A refresh may share a refresh that finished while it waited, but a normal
+ // check may have reused the hardware probes' caches.
c.mu.Lock()
- cached, cachedAt := c.res, c.at
+ cached, cachedAt, cachedMemoryMB := c.res, c.at, c.memoryMB
+ cachedRefreshed := c.refreshed
generation := c.generation
c.mu.Unlock()
- if cached != nil && (!refresh || cachedAt.After(asked)) {
- writeJSON(w, http.StatusOK, cached)
+ if cached != nil && cachedMemoryMB == memoryMB && (!refresh || (cachedRefreshed && cachedAt.After(asked))) {
+ writeResult(cached)
return
}
- res, err := s.mgr.Check(r.Context(), emu.CheckOptions{})
+ res, err := s.mgr.Check(r.Context(), emu.CheckOptions{MemoryMB: memoryMB, Refresh: refresh})
if err != nil {
writeError(w, err)
return
}
c.mu.Lock()
if c.generation == generation {
- c.res, c.at = res, time.Now()
+ c.res, c.at, c.memoryMB, c.refreshed = res, time.Now(), memoryMB, refresh
}
c.mu.Unlock()
- writeJSON(w, http.StatusOK, res)
+ writeResult(res)
}
func (s *server) handleJobs(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/ui/open_other_test.go b/internal/ui/open_other_test.go
index fb64397..5b10061 100644
--- a/internal/ui/open_other_test.go
+++ b/internal/ui/open_other_test.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build !windows && (!darwin || !arm64)
package ui
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index 196719f..e7225bd 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -7,7 +7,7 @@
// boots instances with their window only and has no restart: headless boots, and switching between the two, are not
// its to do (the CLI boots headless; a running instance is never switched).
//
-// Lifetime: the server exits once its window is gone (the Edge process it started exited, or no event stream has
+// Lifetime: the server exits once its window is gone (the browser app process it started exited, or no event stream has
// been connected for IdleExit) and the running jobs are done.
package ui
@@ -21,6 +21,7 @@ import (
"log"
"net"
"net/http"
+ "runtime"
"strconv"
"strings"
"sync"
@@ -40,9 +41,11 @@ type Options struct {
Log *logx.Logger
// Addr to listen on, "127.0.0.1:"; "" = "127.0.0.1:0" (a free port).
Addr string
- // OpenWindow opens the page in an Edge app window (the default browser when Edge is missing) once the server
- // listens.
+ // OpenWindow opens the page after listening: Edge app mode on Windows, the
+ // default browser on macOS, or Chrome app mode when ChromeWindow is set.
OpenWindow bool
+ // ChromeWindow uses Chrome's app mode on macOS instead of its default browser.
+ ChromeWindow bool
// Dev also serves web/dev/ (the page's mock backend).
Dev bool
// Ready, when set, is called once the server listens with its address ("http://127.0.0.1:/") and the login
@@ -185,6 +188,9 @@ func newServer(opt Options) (*server, error) {
}
if s.launch == nil {
s.launch = LaunchWindow
+ if opt.ChromeWindow {
+ s.launch = LaunchChromeWindow
+ }
}
if s.removeOld == nil {
s.removeOld = func(l *logx.Logger) { removeOldEdgeProfile(AppDataDir(), l) }
@@ -251,9 +257,13 @@ func Run(ctx context.Context, opt Options) error {
if w, err = s.launch(base + "?t=" + s.token); err != nil {
s.log.Errorf("打不开管理器窗口: %v", err)
} else if w.Edge {
- s.log.Infof("已用 Edge 应用窗口打开管理器")
+ s.log.Infof("已用 %s 应用窗口打开管理器", appBrowserName())
} else {
- s.log.Infof("没找到 Edge,已用默认浏览器打开管理器")
+ if runtime.GOOS == "darwin" {
+ s.log.Infof("已用默认浏览器打开管理器")
+ } else {
+ s.log.Infof("没找到 Edge,已用默认浏览器打开管理器")
+ }
}
// after the window is up, and never in its way: a failure is only logged
go s.removeOld(s.log)
@@ -298,7 +308,11 @@ func (s *server) waitForExit(ctx context.Context, w *Window) {
case <-edgeDone:
edgeDone = nil
if time.Since(w.Started) < edgeHandoffWindow {
- s.log.Infof("Edge 把窗口交给了已在运行的 Edge;之后按页面连接判断窗口是否关闭")
+ if runtime.GOOS == "darwin" {
+ s.log.Infof("Chrome 把窗口交给了已在运行的 Chrome;之后按页面连接判断窗口是否关闭")
+ } else {
+ s.log.Infof("Edge 把窗口交给了已在运行的 Edge;之后按页面连接判断窗口是否关闭")
+ }
} else {
s.log.Infof("管理器窗口已关闭")
windowClosed = true
@@ -329,13 +343,20 @@ func (s *server) waitForExit(ctx context.Context, w *Window) {
func listenError(addr string, err error) error {
const wsaeacces, wsaeaddrinuse = syscall.Errno(10013), syscall.Errno(10048)
_, port, _ := net.SplitHostPort(addr)
- if errors.Is(err, wsaeaddrinuse) || errors.Is(err, wsaeacces) {
+ if errors.Is(err, wsaeaddrinuse) || errors.Is(err, wsaeacces) || errors.Is(err, syscall.EADDRINUSE) || errors.Is(err, syscall.EACCES) {
return errs.Wrap(errs.PortConflict, err, "管理器用不了端口 "+port+"(被别的程序占用,或被系统保留)").
WithAdvice("换一个端口(--port),或者不带 --port 让系统挑一个空闲端口")
}
return errs.Wrap(errs.Internal, err, "管理器监听 "+addr+" 失败: "+err.Error())
}
+func appBrowserName() string {
+ if runtime.GOOS == "darwin" {
+ return "Chrome"
+ }
+ return "Edge"
+}
+
// hostOK: the Host header names us (127.0.0.1 or localhost with our port).
func (s *server) hostOK(host string) bool {
h, p, err := net.SplitHostPort(host)
@@ -368,6 +389,8 @@ type checkCache struct {
mu sync.Mutex
generation uint64
res *emu.CheckResult
+ memoryMB int
+ refreshed bool
at time.Time
}
diff --git a/internal/ui/web/js/app.js b/internal/ui/web/js/app.js
index b534b99..baf9871 100644
--- a/internal/ui/web/js/app.js
+++ b/internal/ui/web/js/app.js
@@ -7,7 +7,7 @@ import { initUI } from './ui.js';
import { initGrid, renderGrid, viewState, setView } from './cards.js';
import { openCreate, openSettings, openDelete } from './forms.js';
import { openProxy } from './proxy.js';
-import { runCheck, checkSummary, openCheck, openActivity, renderBanner } from './panels.js';
+import { runCheck, syncCheckTarget, checkSummary, openCheck, openActivity, renderBanner } from './panels.js';
import { openViewer, openLogs } from './viewer.js';
import { loadUpdate, onUpdate, renderUpdateChip, openUpdate } from './update.js';
import * as A from './actions.js';
@@ -84,6 +84,7 @@ let opened = false;
// ---- render
function render() {
+ syncCheckTarget();
const root = (S.info && S.info.root) || S.list;
const list = S.list;
if (root) {
diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js
index 5727aec..18a78c4 100644
--- a/internal/ui/web/js/forms.js
+++ b/internal/ui/web/js/forms.js
@@ -214,6 +214,7 @@ export function openSettings(i, field) {
const r = await api.patch(`/api/instances/${i}`, pending);
const k = S.list.instances.findIndex((x) => x.index === i);
if (k >= 0 && r.instance) S.list.instances[k] = r.instance;
+ if ('memory_mb' in pending) S.checkIndex = i;
if ('diagnostics' in pending) {
let text = '诊断模式已开启,立即生效。';
if (!pending.diagnostics) {
diff --git a/internal/ui/web/js/panels.js b/internal/ui/web/js/panels.js
index af3f553..b4cf3d1 100644
--- a/internal/ui/web/js/panels.js
+++ b/internal/ui/web/js/panels.js
@@ -2,7 +2,7 @@
import { h, icon, btn, keyed, showError, setText, attr } from './dom.js';
import { api } from './api.js';
-import { S, update, subscribe, instance, jobsSorted, runningCount } from './store.js';
+import { S, update, subscribe, instance, jobsSorted, runningCount, checkTarget, rootBlocked } from './store.js';
import { openDialog } from './ui.js';
import { openComponentFetch, activeJob, latestJob, progressText, loadDiagnostics, storeBox, fillStoreBox } from './diagnostics.js';
import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, parseTime, pct, stripInst, stopMethod, diskInfoText, diskTrimText, diskCompactText, playStoreText } from './format.js';
@@ -13,21 +13,48 @@ const folderBtn = (cls) => btn(cls, [icon('folder'), '打开目录'], () => A.op
// ---- check
let refreshAgain = false;
+let requestedKey = '';
+let activeKey = '';
+const targetKey = (t) => `${t.index ?? 'default'}:${t.memoryMB}`;
+const targetName = () => {
+ const t = checkTarget();
+ return t.instance ? `「${label(t.instance)}」的下次开机配置` : '新建实例的默认配置';
+};
+
+// A saved setting or an instance-list event can change the target while a check runs.
+export function syncCheckTarget() {
+ if (S.list && targetKey(checkTarget()) !== requestedKey) runCheck(false);
+}
// refresh: a new check, not the cached one. Asked while one runs (components just installed), it runs again after.
export async function runCheck(refresh) {
+ const target = checkTarget();
+ const key = targetKey(target);
+ if (S.check && S.checkTargetKey !== key) {
+ S.check = null;
+ S.checkedAt = 0;
+ update();
+ }
if (S.checking) {
- if (refresh) refreshAgain = true;
+ if (refresh || key !== activeKey) refreshAgain = true;
return;
}
+ activeKey = requestedKey = key;
S.checking = true;
S.checkError = null;
update();
try {
- S.check = await api.get('/api/check' + (refresh ? '?refresh=1' : ''));
- S.checkedAt = Date.now();
+ const query = new URLSearchParams();
+ if (target.index != null) query.set('index', target.index);
+ if (refresh) query.set('refresh', '1');
+ const c = await api.get('/api/check' + (query.size ? '?' + query : ''));
+ if (key === targetKey(checkTarget())) {
+ S.check = c;
+ S.checkTargetKey = key;
+ S.checkedAt = Date.now();
+ }
} catch (e) {
- S.checkError = e;
+ if (key === targetKey(checkTarget())) S.checkError = e;
}
S.checking = false;
update();
@@ -43,7 +70,8 @@ export function checkSummary(c) {
const blockers = items.filter((x) => x.blocking && x.ok === false);
const hints = items.filter((x) => (x.ok === false && !x.blocking) || x.ok == null);
const tone = !c.ready || blockers.length ? 'err' : hints.length ? 'warn' : 'ok';
- return { tone, text: { err: '不能开机', warn: '有提示', ok: '就绪' }[tone], blockers, hints, n: items.length };
+ const text = tone === 'err' && c.root_ready === true ? '内存不足' : { err: '不能开机', warn: '有提示', ok: '就绪' }[tone];
+ return { tone, text, blockers, hints, n: items.length };
}
// ok / not ok / not checked; a failed item that does not block the boot is only a hint
@@ -124,6 +152,12 @@ export function openCheck(opts) {
return;
}
const sum = h('div', { class: 'check-sum' });
+ const target = h('select', { class: 'select', id: 'check-target' });
+ const targetHint = h('p', { class: 'f-hint' });
+ target.addEventListener('change', () => {
+ S.checkIndex = target.value === '' ? null : Number(target.value);
+ runCheck(false);
+ });
const box = h('div', { class: 'form-error', role: 'alert', hidden: true });
const list = h('ul', { class: 'check-list' });
// when something did not pass, the passed items fold away under one line
@@ -139,7 +173,9 @@ export function openCheck(opts) {
const again = btn('btn btn-secondary', null, () => runCheck(true));
const ctl = openDialog({
title: '电脑检查', sub: '开机前会自动检查这些项目,没通过的项目会说明原因和处理办法。', cls: 'dlg-check', light: true,
- body: h('div', { class: 'dlg-body' }, sum, box, list, passed, comps, store.el),
+ body: h('div', { class: 'dlg-body' },
+ h('div', { class: 'fields' }, h('label', { class: 'f-label', for: 'check-target', text: '检查对象' }),
+ h('div', { class: 'f-ctl' }, target, targetHint)), sum, box, list, passed, comps, store.el),
foot: h('footer', { class: 'dlg-foot' }, when, folderBtn('btn btn-ghost'), again),
});
checkCtl = ctl;
@@ -163,6 +199,15 @@ export function openCheck(opts) {
const render = () => {
const c = S.check;
+ const choices = [...(S.list?.instances || [])].sort((a, b) => a.index - b.index);
+ const choicesSig = JSON.stringify(choices.map((x) => [x.index, x.title, x.config?.memory_mb]));
+ if (target.sig !== choicesSig) {
+ target.sig = choicesSig;
+ target.replaceChildren(h('option', { value: '', text: '新建实例(默认 6 GB)' }),
+ ...choices.map((x) => h('option', { value: String(x.index), text: `${label(x)} · ${(x.config?.memory_mb || 6144) / 1024} GB` })));
+ }
+ target.value = checkTarget().index == null ? '' : String(checkTarget().index);
+ setText(targetHint, `按${targetName()}检查;每次开机前会重新检查可用内存。`);
store.el.hidden = !(S.components && S.components.play_store);
if (!store.el.hidden) fillStoreBox(store);
showStore();
@@ -191,7 +236,7 @@ export function openCheck(opts) {
}
const s = checkSummary(c);
const [head, line] = {
- err: ['暂时不能开机', `${s.blockers.length} 项必需检查没通过,按下面的建议处理后再检查一次。`],
+ err: ['当前检查配置暂时不能开机', `${s.blockers.length} 项必需检查没通过,按下面的建议处理后再检查一次。`],
warn: ['可以开机,有提示', `${s.hints.length} 项需要留意,不影响开机。`],
ok: ['这台电脑可以开机', `${s.n} 项检查都通过了。`],
}[s.tone];
@@ -230,7 +275,7 @@ export function openCheck(opts) {
// ---- banner: the root cannot boot (a blocking check failed or components are missing)
export function renderBanner(el) {
const c = S.check;
- const sig = c && !c.ready ? JSON.stringify([c.items, c.components]) : '';
+ const sig = c && !c.ready ? JSON.stringify([c.items, c.components, targetName()]) : '';
if (el.sig === sig) return;
el.sig = sig;
el.hidden = !sig;
@@ -241,7 +286,7 @@ export function renderBanner(el) {
el.replaceChildren(h('div', { class: 'banner', role: 'status' },
h('span', { class: 'banner-ic' }, icon('alert')),
h('div', { class: 'banner-main' },
- h('h2', { text: '这个目录现在还不能开机' }),
+ h('h2', { text: rootBlocked() ? '这个目录现在还不能开机' : `${targetName()}暂不满足启动条件` }),
first && h('p', { text: first.reason }),
first && first.advice && advice(first.id === 'components'
? '请点下面的「补全组件」打开许可协议并下载,或运行 tools\\modavd.exe components fetch。'
diff --git a/internal/ui/web/js/store.js b/internal/ui/web/js/store.js
index 0d23e76..9442288 100644
--- a/internal/ui/web/js/store.js
+++ b/internal/ui/web/js/store.js
@@ -8,6 +8,8 @@ export const S = {
watch: new Set(), // job ids started from this window (toast when they finish)
logs: [], // {time, level, msg, job_id}
check: null, // CheckResult
+ checkIndex: undefined, // undefined: first instance; null: new-instance defaults; number: selected instance
+ checkTargetKey: '',
checking: false,
checkError: null,
checkedAt: 0,
@@ -63,8 +65,21 @@ export function lastJob(i, running) {
return best;
}
-// The root cannot boot right now (the banner says why); 开机 would only fail.
-export const rootBlocked = () => !!S.check && S.check.ready === false;
+// Memory belongs to one boot's configuration and is checked freshly by Start.
+// Only common host/SDK failures may disable every instance's start button.
+export function rootBlocked() {
+ const c = S.check;
+ if (!c) return false;
+ if (typeof c.root_ready === 'boolean') return !c.root_ready;
+ const items = c.items || [];
+ return c.ready === false && (!items.length || items.some((x) => x.id !== 'memory' && x.blocking && x.ok !== true));
+}
+
+export function checkTarget() {
+ const instances = S.list?.instances || [];
+ const selected = S.checkIndex === null ? null : instance(S.checkIndex) || [...instances].sort((a, b) => a.index - b.index)[0];
+ return { index: selected?.index ?? null, memoryMB: selected?.config?.memory_mb || 6144, instance: selected };
+}
export function runningCount() {
let n = 0;
diff --git a/internal/ui/window_darwin.go b/internal/ui/window_darwin.go
new file mode 100644
index 0000000..976678c
--- /dev/null
+++ b/internal/ui/window_darwin.go
@@ -0,0 +1,36 @@
+//go:build darwin && arm64
+
+package ui
+
+import (
+ "path/filepath"
+ "time"
+
+ "modavd/internal/host"
+ "modavd/internal/logx"
+)
+
+type Window struct {
+ // Edge is the historical name of the owned app-window flag; macOS uses Chrome.
+ Edge bool
+ Started time.Time
+ Done <-chan struct{}
+}
+
+func LaunchWindow(url string) (*Window, error) { return launchChromeWindow(url, false) }
+
+func launchChromeWindow(url string, chrome bool) (*Window, error) {
+ w, err := host.LaunchManagerWindow(url, chrome)
+ if err != nil {
+ return nil, err
+ }
+ return &Window{Edge: w.App, Started: w.Started, Done: w.Done}, nil
+}
+
+func AppDataDir() string { return host.ManagerDataDir() }
+
+// Retained for common cleanup routing; no Edge profile is opened or removed on macOS.
+func EdgeProfileDir() string { return filepath.Join(AppDataDir(), "ui-edge2") }
+func removeOldEdgeProfile(string, *logx.Logger) {}
+func FindEdge() string { return "" }
+func shellOpen(target string) error { return host.OpenDesktop(target) }
diff --git a/internal/ui/window_other.go b/internal/ui/window_other.go
index dab40c8..6850a8b 100644
--- a/internal/ui/window_other.go
+++ b/internal/ui/window_other.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build !windows && (!darwin || !arm64)
package ui