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