diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fdb188..ae97a9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - name: display coordinate tests run: node --test scripts/test_display_tap.mjs - name: disk UI tests - run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs + run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs scripts/test_card_cfg.mjs - name: gofmt shell: pwsh run: | diff --git a/cmd/modavd-gui/rsrc_windows_amd64.syso b/cmd/modavd-gui/rsrc_windows_amd64.syso index 0d42ad3..6c33eef 100644 Binary files a/cmd/modavd-gui/rsrc_windows_amd64.syso and b/cmd/modavd-gui/rsrc_windows_amd64.syso differ diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 0009b84..1ae633d 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -4,7 +4,7 @@ package buildinfo // Version of modavd itself, kept in the source rather than set with -ldflags -X: release builds must come out byte // for byte the same for the same source (Mirror 酱 ships per-file deltas, and a version stamped per package would // change the exe in every release). The ModAVD package version comes from the root at run time. -var Version = "0.8.0" +var Version = "0.9.0" // Protocol is the version of the machine-readable contract: the commands, the JSON written by `--json`, the exit codes // and the MCP tool schemas. Callers such as AUTO-MAS check it (`modavd version --json`) before relying on a field. diff --git a/internal/cli/format.go b/internal/cli/format.go index fb903ec..85809ac 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -185,8 +185,8 @@ func formatList(w io.Writer, r *emu.ListResult) { rows := [][]string{{"编号", "名称", "状态", "adb 地址", "内存", "核数", "帧率", "备注"}} for _, in := range r.Instances { title := in.Title - if title == "" { - title = in.Name + if title == "" || title == in.Name { // no title of its own: emu-N, as the manager shows it (mas_N is the file name) + title = fmt.Sprintf("emu-%d", in.Index) } rows = append(rows, []string{ strconv.Itoa(in.Index), title, statusLabel(in.Status, in.CreationProgress), in.AdbAddress, diff --git a/internal/cli/format_test.go b/internal/cli/format_test.go index 87ca79c..460eea2 100644 --- a/internal/cli/format_test.go +++ b/internal/cli/format_test.go @@ -69,7 +69,7 @@ func TestFormatListTable(t *testing.T) { !strings.Contains(table[1], "127.0.0.1:20001") || !strings.Contains(table[1], "6 GB") { t.Errorf("row 0 = %q", table[1]) } - if !strings.Contains(table[2], "mas_3") || !strings.Contains(table[2], "开机中") || + if strings.Contains(table[2], "mas_3") || !strings.Contains(table[2], "emu-3") || !strings.Contains(table[2], "开机中") || !strings.Contains(table[2], "正在开机,未初始化,软件渲染") || !strings.Contains(table[2], "4 GB") { t.Errorf("row 1 = %q", table[2]) } diff --git a/internal/emu/bootwatch.go b/internal/emu/bootwatch.go index 3c65c4c..b7dd6bc 100644 --- a/internal/emu/bootwatch.go +++ b/internal/emu/bootwatch.go @@ -18,11 +18,15 @@ package emu // The watch never waits: while another start or a stop runs it looks again at the next tick, and a start that sets up // a boot writes its record, so a boot is never set up twice. Its messages go to \logs\modavd-bootwatch.log (one // JSON object per line, as `--json` on stderr). +// +// Being the one process that lives as long as the emulator, the watch also writes the emulator's memory to that log +// every bootWatchMemoryInterval (logMemory). import ( "context" "encoding/json" "errors" + "fmt" "io" "io/fs" "os" @@ -261,8 +265,10 @@ func RunBootWatch(args []string, w io.Writer) int { } cancel() }() + watch.logMemory(time.Now()) for !q.Wait(bootWatchInterval) { watch.tick(ctx, time.Now()) + watch.logMemory(time.Now()) } m.Wait(10 * time.Second) log.Infof("实例 %d 的模拟器(qemu pid %d)已退出,开机守护结束", i, q.PID) @@ -277,6 +283,40 @@ type bootWatch struct { // missingSince: since when the private server has not known the instance (zero: it did at the last look), or // since the watch last registered it again (bootWatchReregisterAfter). missingSince time.Time + // memoryAt: when logMemory wrote its last line (zero: not yet). + memoryAt time.Time +} + +// bootWatchMemoryInterval: how often the watch writes the emulator's memory to its log. +const bootWatchMemoryInterval = 10 * time.Minute + +// The memory counters behind logMemory; tests replace them. +var ( + processMemory = host.GetProcessMemory + systemMemory = host.GetSystemMemory +) + +// logMemory writes one line with the memory of qemu and of the computer, at the watch's start and then every +// bootWatchMemoryInterval: a report that the computer got slow after hours of running can then be told apart into +// the emulator's commit growing (a leak on the host side) and the computer simply being short of memory. Nothing +// where the counters cannot be read. +func (w *bootWatch) logMemory(now time.Time) { + if !w.memoryAt.IsZero() && now.Sub(w.memoryAt) < bootWatchMemoryInterval { + return + } + pm, err := processMemory(w.q) + if err != nil { + return + } + w.memoryAt = now + const mb = 1 << 20 + msg := fmt.Sprintf("实例 %d 模拟器内存(qemu pid %d):提交 %d MB(最高 %d MB),工作集 %d MB", w.i, w.q.PID, + pm.Commit/mb, pm.PeakCommit/mb, pm.WorkingSet/mb) + if sm, err := systemMemory(); err == nil { + msg += fmt.Sprintf(";电脑:物理内存可用 %d / %d MB,提交 %d / %d MB", sm.AvailPhys/mb, sm.TotalPhys/mb, + sm.Committed/mb, sm.CommitLimit/mb) + } + w.m.Log.Infof("%s", msg) } // tick is one look of the watch at now: nothing while a start or a stop of the instance runs, or while the record does diff --git a/internal/emu/bootwatch_memory_test.go b/internal/emu/bootwatch_memory_test.go new file mode 100644 index 0000000..cbc9cac --- /dev/null +++ b/internal/emu/bootwatch_memory_test.go @@ -0,0 +1,76 @@ +//go:build windows + +package emu + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "modavd/internal/host" + "modavd/internal/logx" +) + +// logMemory writes a line at the first look and then once per bootWatchMemoryInterval, with qemu's and the computer's +// numbers in MB; nothing while qemu's counters cannot be read, and only qemu's part without the computer's. +func TestBootWatchLogMemory(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + const mb = 1 << 20 + var perr, serr error + processMemory = func(p host.Process) (host.ProcessMemory, error) { + if p.PID != 4242 { + t.Errorf("asked for pid %d", p.PID) + } + return host.ProcessMemory{Commit: 6100 * mb, PeakCommit: 6200 * mb, WorkingSet: 4300 * mb}, perr + } + systemMemory = func() (host.SystemMemory, error) { + return host.SystemMemory{TotalPhys: 15728 * mb, AvailPhys: 4400 * mb, CommitLimit: 41574 * mb, Committed: 38600 * mb}, serr + } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + + perr = errors.New("gone") + w.logMemory(t0) + if len(*lines) != 0 { + t.Fatalf("logged without counters: %+v", *lines) + } + perr = nil + w.logMemory(t0) + w.logMemory(t0.Add(bootWatchMemoryInterval - time.Second)) + if len(*lines) != 1 { + t.Fatalf("lines = %+v, want one", *lines) + } + want := "实例 3 模拟器内存(qemu pid 4242):提交 6100 MB(最高 6200 MB),工作集 4300 MB;电脑:物理内存可用 4400 / 15728 MB,提交 38600 / 41574 MB" + if got := (*lines)[0]; got.level != logx.Info || got.msg != want { + t.Errorf("line = %v %q\nwant %q", got.level, got.msg, want) + } + serr = errors.New("no") + w.logMemory(t0.Add(bootWatchMemoryInterval)) + if len(*lines) != 2 || !strings.HasSuffix((*lines)[1].msg, "工作集 4300 MB") { + t.Errorf("without the computer's numbers: %+v", *lines) + } +} + +// The real counters read this test process and the computer. +func TestMemoryCounters(t *testing.T) { + p, ok := host.FindProcess(uint32(os.Getpid())) + if !ok { + t.Fatal("own process not found") + } + pm, err := host.GetProcessMemory(p) + if err != nil || pm.Commit == 0 || pm.WorkingSet == 0 || pm.PeakCommit < pm.Commit { + t.Errorf("process memory = %+v, %v", pm, err) + } + sm, err := host.GetSystemMemory() + if err != nil || sm.TotalPhys == 0 || sm.AvailPhys > sm.TotalPhys || sm.CommitLimit < sm.TotalPhys || + sm.Committed == 0 || sm.Committed > sm.CommitLimit { + t.Errorf("system memory = %+v, %v", sm, err) + } + if _, err := host.GetProcessMemory(host.Process{PID: p.PID, Created: p.Created + 1}); err == nil { + t.Error("a reused PID read as the process") + } +} diff --git a/internal/emu/check.go b/internal/emu/check.go index eae2861..7eacb37 100644 --- a/internal/emu/check.go +++ b/internal/emu/check.go @@ -79,20 +79,6 @@ 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/memory.go b/internal/host/memory.go new file mode 100644 index 0000000..1d754af --- /dev/null +++ b/internal/host/memory.go @@ -0,0 +1,16 @@ +package host + +// ProcessMemory is the memory of one process, in bytes. +type ProcessMemory struct { + Commit uint64 // private commit charge (Task Manager's "Commit size") + PeakCommit uint64 // the highest Commit has been + WorkingSet uint64 // in physical memory now +} + +// SystemMemory is the memory of the computer, in bytes. +type SystemMemory struct { + TotalPhys uint64 + AvailPhys uint64 + CommitLimit uint64 // physical memory plus page files + Committed uint64 +} diff --git a/internal/host/unsupported_other.go b/internal/host/unsupported_other.go index cfc66c3..4cd9361 100644 --- a/internal/host/unsupported_other.go +++ b/internal/host/unsupported_other.go @@ -31,6 +31,12 @@ func SetAffinity(Process, uint64) error { return errUnsupported } // GetAffinity calls GetProcessAffinityMask on Windows. func GetAffinity(Process) (uint64, error) { return 0, errUnsupported } +// GetProcessMemory calls GetProcessMemoryInfo on Windows. +func GetProcessMemory(Process) (ProcessMemory, error) { return ProcessMemory{}, errUnsupported } + +// GetSystemMemory calls GlobalMemoryStatusEx on Windows. +func GetSystemMemory() (SystemMemory, error) { return SystemMemory{}, errUnsupported } + // L3Groups lists the L3 caches of the host. func L3Groups() []L3Group { return nil } diff --git a/internal/host/windows.go b/internal/host/windows.go index f51539c..1a69521 100644 --- a/internal/host/windows.go +++ b/internal/host/windows.go @@ -31,6 +31,18 @@ func FindProcess(pid uint32) (Process, bool) { return win.FindProcess(pid) } func ProcessName(pid uint32) string { return win.ProcessName(pid) } func KillTree(p Process) error { return win.KillTree(p) } +// GetProcessMemory reads the memory counters of p; an error when p is gone or cannot be opened. +func GetProcessMemory(p Process) (ProcessMemory, error) { + commit, peak, ws, err := p.Memory() + return ProcessMemory{Commit: commit, PeakCommit: peak, WorkingSet: ws}, err +} + +// GetSystemMemory reads the computer's physical memory and commit charge. +func GetSystemMemory() (SystemMemory, error) { + total, avail, limit, committed, err := win.SystemMemory() + return SystemMemory{TotalPhys: total, AvailPhys: avail, CommitLimit: limit, Committed: committed}, err +} + func StartDetached(exe string, args []string, opt StartOptions) (Process, error) { return win.StartDetached(exe, args, opt) } diff --git a/internal/report/collect.go b/internal/report/collect.go index 8750be1..26d270e 100644 --- a/internal/report/collect.go +++ b/internal/report/collect.go @@ -735,6 +735,62 @@ func otherRootsInstance(plan root.PortPlan, avd string, port int) bool { return err == nil && port != plan.Console(i) } +// topMemoryCount: how many programs topMemory lists. +const topMemoryCount = 10 + +// topMemory lists the programs that hold the most memory, processes of one name added up, by commit: when the +// computer is short of memory the list says whether it is the emulator or something else. No section when no +// process's memory could be read. +func (c *collector) topMemory(procs []Process) { + type sum struct { + name string + n int + commit, workingSet uint64 + } + byName := map[string]*sum{} + var total uint64 + for _, p := range procs { + if p.CommitBytes == 0 { + continue + } + key := strings.ToLower(p.Name) + s := byName[key] + if s == nil { + s = &sum{name: p.Name} + byName[key] = s + } + s.n++ + s.commit += p.CommitBytes + s.workingSet += p.WorkingSetBytes + total += p.CommitBytes + } + if len(byName) == 0 { + return + } + sums := make([]*sum, 0, len(byName)) + for _, s := range byName { + sums = append(sums, s) + } + sort.Slice(sums, func(a, b int) bool { + if sums[a].commit != sums[b].commit { + return sums[a].commit > sums[b].commit + } + return sums[a].name < sums[b].name + }) + c.section(fmt.Sprintf("占内存最多的程序(按提交排,前 %d 个;同名进程合在一起)", topMemoryCount)) + for k, s := range sums { + if k == topMemoryCount { + break + } + count := "" + if s.n > 1 { + count = fmt.Sprintf("(%d 个进程)", s.n) + } + c.L(fmt.Sprintf("%s%s:提交 %s,工作集 %s", s.name, count, formatSize(float64(s.commit)), formatSize(float64(s.workingSet)))) + } + c.L(fmt.Sprintf("读得到的 %d 个程序合计提交 %s", len(sums), formatSize(float64(total)))) +} + func (c *collector) runningAndAdb() error { c.section("正在运行的 qemu-system 进程") qs, err := c.h.Processes(func(name string) bool { return hasPrefixFold(name, "qemu-system") }) @@ -751,6 +807,10 @@ func (c *collector) runningAndAdb() error { ct = q.Started.Local().Format("2006-01-02 15:04:05") } c.L(fmt.Sprintf("pid %d 启动于 %s", q.PID, ct)) + if q.CommitBytes != 0 { + c.L(fmt.Sprintf(" 内存:提交 %s,工作集 %s(隔 10 分钟的记录在 modavd-bootwatch.log)", + formatSize(float64(q.CommitBytes)), formatSize(float64(q.WorkingSetBytes)))) + } cmd := q.CommandLine if cmd == "" { c.L(" qemu 命令行读不到(多半是模拟器以管理员身份运行),现场按 adb 设备列表推算") @@ -775,6 +835,9 @@ func (c *collector) runningAndAdb() error { } } c.running = running + if all, err := c.h.Processes(func(string) bool { return true }); err == nil { + c.topMemory(all) + } c.section("正在监听的 adb server") ls, err := c.adbListeners() diff --git a/internal/report/host.go b/internal/report/host.go index 357a12d..3ec140c 100644 --- a/internal/report/host.go +++ b/internal/report/host.go @@ -143,6 +143,9 @@ type Process struct { Started time.Time // zero when unknown CommandLine string // "" when unreadable ExePath string // "" when unreadable + // CommitBytes, WorkingSetBytes: private commit charge and working set; both 0 when unreadable. + CommitBytes uint64 + WorkingSetBytes uint64 } type Listener struct { diff --git a/internal/report/host_windows.go b/internal/report/host_windows.go index 3eb197c..bb9c316 100644 --- a/internal/report/host_windows.go +++ b/internal/report/host_windows.go @@ -480,6 +480,9 @@ func (h *winHost) Processes(match func(name string) bool) ([]Process, error) { } else { q.ExePath, _ = p.ImagePath() } + if pm, err := host.GetProcessMemory(p); err == nil { + q.CommitBytes, q.WorkingSetBytes = pm.Commit, pm.WorkingSet + } out = append(out, q) } return out, nil diff --git a/internal/report/report_test.go b/internal/report/report_test.go index fb122a1..7aa06d8 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -177,6 +177,51 @@ func TestCollectRunningInstancesRespectDefaultPlan(t *testing.T) { } } +// The memory section adds up processes of one name, sorts by commit, stops at topMemoryCount and leaves out +// processes whose memory could not be read; without any readable one there is no section. +func TestCollectTopMemory(t *testing.T) { + const mb = 1 << 20 + procs := []Process{ + {PID: 1, Name: "qemu-system-x86_64.exe", CommitBytes: 6144 * mb, WorkingSetBytes: 4096 * mb}, + {PID: 2, Name: "browser.exe", CommitBytes: 4096 * mb, WorkingSetBytes: 1024 * mb}, + {PID: 3, Name: "Browser.exe", CommitBytes: 4096 * mb, WorkingSetBytes: 1024 * mb}, + {PID: 4, Name: "unreadable.exe"}, + } + for k := 0; k < topMemoryCount; k++ { + procs = append(procs, Process{PID: uint32(10 + k), Name: fmt.Sprintf("small%02d.exe", k), CommitBytes: uint64(100-k) * mb}) + } + h := portProcessHost{Host: &fakeHost{sc: &scenario{}}, processes: procs} + c := &collector{h: h} + if err := c.runningAndAdb(); err != nil { + t.Fatal(err) + } + out := strings.Join(c.lines, "\n") + _, sec, ok := strings.Cut(out, "占内存最多的程序") + if !ok { + t.Fatalf("no memory section:\n%s", out) + } + sec, _, _ = strings.Cut(sec, "\n\n") + lines := strings.Split(strings.TrimSpace(sec), "\n")[1:] + if len(lines) != topMemoryCount+1 || + lines[0] != "browser.exe(2 个进程):提交 8.00 GB,工作集 2.00 GB" || + lines[1] != "qemu-system-x86_64.exe:提交 6.00 GB,工作集 4.00 GB" || + !strings.HasPrefix(lines[2], "small00.exe:") || !strings.HasPrefix(lines[topMemoryCount-1], "small07.exe:") || + !strings.HasPrefix(lines[topMemoryCount], "读得到的 12 个程序合计提交 ") { + t.Errorf("memory section:\n%s", strings.Join(lines, "\n")) + } + if strings.Contains(sec, "unreadable.exe") { + t.Error("lists a process without memory numbers") + } + + c = &collector{h: portProcessHost{Host: &fakeHost{sc: &scenario{}}, processes: procs[3:4]}} + if err := c.runningAndAdb(); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(c.lines, "\n"), "占内存最多的程序") { + t.Error("a memory section without numbers") + } +} + func TestCollectBridgeLogs(t *testing.T) { r := testutil.TempDir(t) dir := filepath.Join(r, root.LogsDir) diff --git a/internal/root/root.go b/internal/root/root.go index 67baea7..39d53c1 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -1016,30 +1016,58 @@ func ParseVersion(text string) (major, build, patch int, ok bool) { return 0, 0, 0, false } -// manifestVersion is modavd-manifest.json's "version", "" when the file is missing / unreadable / not an object or -// the value is not a string. Read like readProperties (the updater replaces it). -func (r *Root) manifestVersion() string { +// LastCoupledBuild is the last emulator build whose ModAVD version was 0.. (mas-40 = 0.40.x). Later +// versions are numbered on their own; the package says its emulator build in modavd-manifest.json's "build". +const LastCoupledBuild = 40 + +// manifestInfo is modavd-manifest.json's "version" ("" when missing or not a string) and "build" (hasBuild=false when +// missing or not an integer); both empty when the file is missing / unreadable / not an object. Read like +// readProperties (the updater replaces it). +func (r *Root) manifestInfo() (version string, build int, hasBuild bool) { data, err := ReadFileShared(r.ManifestPath()) if err != nil { - return "" + return "", 0, false } + data = bytes.TrimPrefix(data, []byte("\xEF\xBB\xBF")) v, err := jsonord.Parse(data) if err != nil { - return "" + return "", 0, false + } + o := v.Obj() + version, _ = o.Get("version").Str() + if n, ok := o.Get("build").Int(); ok { + return version, int(n), true + } + return version, 0, false +} + +// InstalledVersion is the installed package's ModAVD version. It is the manifest's "version" when the manifest +// belongs to the emulator in sdk\emulator: its "build" is ModBuild (a manifest without "build", from before the two +// were separate: its minor number is ModBuild), or ModBuild is unknown. Otherwise, for an emulator up to +// LastCoupledBuild (no manifest, or sdk\emulator replaced by hand) it is 0..0, as those were numbered; +// else unknown. +func (r *Root) InstalledVersion() (major, minor, patch int, ok bool) { + build, haveBuild := r.ModBuild() + text, declared, hasDeclared := r.manifestInfo() + if major, minor, patch, ok := ParseVersion(text); ok { + if !haveBuild || (hasDeclared && declared == build) || (!hasDeclared && minor == build) { + return major, minor, patch, true + } + } + if haveBuild && build <= LastCoupledBuild { + return 0, build, 0, true } - s, _ := v.Obj().Get("version").Str() - return s + return 0, 0, 0, false } -// VersionText is the ModAVD version for people, "0.35.0": the manifest's version when its build matches ModBuild, -// else 0..0; "" when ModBuild is unknown. Same rule as AUTO-MAS local_version_text. +// VersionText is the ModAVD version for people, "0.35.0" (InstalledVersion); "" when ModBuild or the version is +// unknown. func (r *Root) VersionText() string { - build, ok := r.ModBuild() - if !ok { + if _, ok := r.ModBuild(); !ok { return "" } - if major, b, patch, ok := ParseVersion(r.manifestVersion()); ok && b == build { - return fmt.Sprintf("%d.%d.%d", major, b, patch) + if major, minor, patch, ok := r.InstalledVersion(); ok { + return fmt.Sprintf("%d.%d.%d", major, minor, patch) } - return fmt.Sprintf("0.%d.0", build) + return "" } diff --git a/internal/root/root_test.go b/internal/root/root_test.go index bbea507..b5ffe49 100644 --- a/internal/root/root_test.go +++ b/internal/root/root_test.go @@ -992,6 +992,35 @@ func TestModBuildAndVersionText(t *testing.T) { t.Errorf("manifest %q: %q want %q", content, got, want) } } + // The manifest's "build" (the emulator build) decides, not the version's minor number: from 0.41 they differ. + for content, want := range map[string]string{ + `{"version": "v0.41.0", "build": 35}`: "0.41.0", + `{"version": "v0.35.2", "build": 35}`: "0.35.2", + `{"version": "v0.35.0", "build": 34}`: "0.35.0", // the manifest is another build's: emulator mas-35 is 0.35.0 + `{"version": "v0.35.0", "build": "35"}`: "0.35.0", // not an integer: as a manifest without build + } { + writeFile(t, manifest, []byte(content)) + if got := r.VersionText(); got != want { + t.Errorf("manifest %q: %q want %q", content, got, want) + } + } + // Past mas-40 there is no 0..0: only a manifest of that build gives the version. + writeFile(t, props, []byte("Pkg.BuildId=mas-41\n")) + for content, want := range map[string]string{ + `{"version": "v0.42.0", "build": 41}`: "0.42.0", + `{"version": "v0.41.0"}`: "0.41.0", + `{"version": "v0.42.0", "build": 40}`: "", + `{"version": "v0.42.0"}`: "", + } { + writeFile(t, manifest, []byte(content)) + if got := r.VersionText(); got != want { + t.Errorf("mas-41, manifest %q: %q want %q", content, got, want) + } + } + os.Remove(manifest) + if got := r.VersionText(); got != "" { + t.Errorf("mas-41 without manifest: %q", got) + } writeFile(t, props, []byte("Pkg.BuildId=12345\n")) writeFile(t, manifest, []byte(`{"version": "v0.35.0"}`)) if got := r.VersionText(); got != "" { diff --git a/internal/ui/API.en.md b/internal/ui/API.en.md index 2be30bc..f938e0b 100644 --- a/internal/ui/API.en.md +++ b/internal/ui/API.en.md @@ -74,7 +74,7 @@ HTTP 4xx / 5xx with the body `{"error":{"code":"…","message":"…","advice":" | GET | `/api/diag/report-config` | | `{"text":"…"}` | | GET | `/api/boss-keys` | | `{"supported","taken"}`: `supported` means this manager has boss keys (Windows, and the tray icon could be put up); `taken` are the keys known to be held by another program (registering or trying failed), `[]` when none | | POST | `/api/boss-keys/check` | `{key}` | `{"key","taken"}`: `key` as it would be stored (`"Ctrl+Alt+Shift+1"`; `""` and `"none"` mean no key and come back as `""`), `taken` means registering it now (released at once) failed; a key this manager has registered for another instance does not count. A malformed key is 400 `invalid_argument`, no boss keys is 422 `unsupported` | -| GET | `/api/check?index={i}&refresh=1` | both parameters may be omitted | The original fields of `CheckResult`, plus `instance_index`, `memory_mb` and `root_ready` (takes a few seconds: the hardware acceleration check and the Vulkan probe) | +| GET | `/api/check?refresh=1` | `refresh` may be omitted | `CheckResult` (takes a few seconds: the hardware acceleration check and the Vulkan probe) | | GET | `/api/jobs` | | `{"jobs":[Job]}`, newest first, at most 50 | | GET | `/api/jobs/{id}` | | `Job` | | POST | `/api/jobs/{id}/cancel` | | `Job` | @@ -90,7 +90,7 @@ HTTP 4xx / 5xx with the body `{"error":{"code":"…","message":"…","advice":" - Screenshot, input, listing apps and getting the GSF ID on an instance that is not running: 409 with code `busy`. - `InstanceInfo` carries `diagnostics: bool`, and `diagnostics_since` when it is on. When PATCH changes diagnostics, `UpdateResult` carries `diagnostics_cleanup: {files, bytes}`, reporting only what was actually deleted. The preview for turning it off includes old logs of other ordinary instances that must be evicted after rejoining the ordinary root-wide 1 GiB pool; active files are outside the scope of both the preview and the deletion. The state on disk may change between the preview and the commit, so the page goes by the result of the commit. If turning it off partly fails, the switch is already saved as off, and submitting `diagnostics: false` again continues the cleanup and the guest restoration. - When the diagnostics setting succeeds but the host logcat is collected by an external program, `UpdateResult.warnings` says that it was not taken over; the external process is not ended, and the call does not wait for it to switch before returning. The front end shows this notice. -- The `index` of the computer check selects the check target: it reads that instance's `config.memory_mb` for its next boot, returns 404 if it does not exist, and 400 `invalid_argument` if `index` is empty, repeated or an invalid number. When it is not given, the check uses the default 6144 MB and `instance_index` is `null`; when given, it returns that number. `memory_mb` is always the memory actually used by this check. The cache is keyed by that memory value; after an instance's memory is changed, the result of the old configuration is not reused, and `refresh=1` re-runs both the hardware acceleration and the graphics checks. `ready` includes this check's memory check, while `root_ready` looks only at the general blockers other than memory; on macOS, a general blocker that cannot be confirmed also counts as not passed. Insufficient memory for one check target should not disable other instances; at boot, the pre-check is still redone against the target instance's actual memory. +- The computer check covers this computer and this root folder: one result shared by every instance, and host memory is not checked (a boot the emulator cannot get memory for fails with `insufficient_memory`). The result is cached; `refresh=1` re-runs the hardware acceleration and the graphics checks. When `ready` is `false`, no instance can boot; on macOS, a blocker that cannot be confirmed also counts as not passed. ### Job diff --git a/internal/ui/API.md b/internal/ui/API.md index 1fdbd80..680acd1 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -74,7 +74,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | GET | `/api/diag/report-config` | | `{"text":"…"}` | | GET | `/api/boss-keys` | | `{"supported","taken"}`:`supported` 是这个管理器有老板键(Windows,托盘图标放成了);`taken` 是已知被别的程序占着的键(注册或试注册没成),没有是 `[]` | | POST | `/api/boss-keys/check` | `{key}` | `{"key","taken"}`:`key` 是存下来的写法(`"Ctrl+Alt+Shift+1"`;`""`、`"none"` 是不要,回 `""`),`taken` 是现在试注册(注册上马上注销)没成;这个管理器自己为别的实例注册着的不算。键写得不对 400 `invalid_argument`,没有老板键 422 `unsupported` | -| GET | `/api/check?index={i}&refresh=1` | 两个参数均可省略 | `CheckResult` 的原有字段,加 `instance_index`、`memory_mb`、`root_ready`(要几秒:硬件加速检查和 Vulkan 探测) | +| GET | `/api/check?refresh=1` | `refresh` 可省略 | `CheckResult`(要几秒:硬件加速检查和 Vulkan 探测) | | GET | `/api/jobs` | | `{"jobs":[Job]}`,新的在前,最多 50 个 | | GET | `/api/jobs/{id}` | | `Job` | | POST | `/api/jobs/{id}/cancel` | | `Job` | @@ -90,7 +90,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 无法确认的通用阻断项也视为未通过。某个检查目标内存不足不应禁用其他实例,开机时仍按目标实例实际内存重新预检。 +- 电脑检查查的是这台电脑和这个根目录,所有实例共用一份结果,不查内存(模拟器分不到内存时开机报 `insufficient_memory`)。结果会缓存,`refresh=1` 重新执行硬件加速和图形检查。`ready` 为 `false` 时所有实例都开不了机;macOS 无法确认的阻断项也视为未通过。 ### Job diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index de97116..ee6a5f8 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "modavd/internal/avd" "modavd/internal/buildinfo" "modavd/internal/components" "modavd/internal/emu" @@ -507,37 +506,9 @@ func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) { writeError(w, errs.New(errs.InvalidArgument, "检查参数不合法")) return } + // The check is the host's and the root's, the same for every instance: host memory is not checked. 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()}) - } + writeResult := func(res *emu.CheckResult) { writeJSON(w, http.StatusOK, res) } asked := time.Now() c := &s.check if err := c.lock(r.Context()); err != nil { @@ -548,22 +519,22 @@ func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) { // 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, cachedMemoryMB := c.res, c.at, c.memoryMB + cached, cachedAt := c.res, c.at cachedRefreshed := c.refreshed generation := c.generation c.mu.Unlock() - if cached != nil && cachedMemoryMB == memoryMB && (!refresh || (cachedRefreshed && cachedAt.After(asked))) { + if cached != nil && (!refresh || (cachedRefreshed && cachedAt.After(asked))) { writeResult(cached) return } - res, err := s.mgr.Check(r.Context(), emu.CheckOptions{MemoryMB: memoryMB, Refresh: refresh}) + res, err := s.mgr.Check(r.Context(), emu.CheckOptions{Refresh: refresh}) if err != nil { writeError(w, err) return } c.mu.Lock() if c.generation == generation { - c.res, c.at, c.memoryMB, c.refreshed = res, time.Now(), memoryMB, refresh + c.res, c.at, c.refreshed = res, time.Now(), refresh } c.mu.Unlock() writeResult(res) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 5858ffe..02c1951 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -486,7 +486,6 @@ 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 baf9871..b534b99 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, syncCheckTarget, checkSummary, openCheck, openActivity, renderBanner } from './panels.js'; +import { runCheck, 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,7 +84,6 @@ 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/cards.js b/internal/ui/web/js/cards.js index 4a6cc34..ce41bc0 100644 --- a/internal/ui/web/js/cards.js +++ b/internal/ui/web/js/cards.js @@ -3,7 +3,7 @@ import { h, icon, btn, ibtn, keyed, setText, attr } from './dom.js'; import { S, lastJob, rootBlocked } from './store.js'; -import { label, uptime, stripInst, pct, cfgText } from './format.js'; +import { label, ownTitle, uptime, stripInst, pct, cfgText } from './format.js'; import * as A from './actions.js'; import { openMenu } from './ui.js'; import { openSettings, openDelete } from './forms.js'; @@ -143,7 +143,7 @@ function makeCard(i) { c.pmark = btn('pmark', [icon('shield'), h('span', { text: '代理' })], () => openProxy(i), { hidden: true }); c.sub = h('div', { class: 'card-sub' }); c.lmsg = h('div', { class: 'card-sub card-lmsg' }); - c.cfg = h('div', { class: 'card-cfg', title: '内存 · CPU 核数 · 帧率 · 数据盘(自动扩盘或指定大小,都按实际写入占电脑硬盘)' }); + c.cfg = h('div', { class: 'card-cfg', title: '内存 · CPU 核数 · 帧率 · 数据盘(指定大小时)· 数据盘现在实际占的电脑硬盘' }); c.pillText = h('span'); c.pill = h('span', { class: 'pill' }, c.pillText); c.adb = h('span', { class: 'adb-addr' }); @@ -186,13 +186,13 @@ function updateCard(c, inst) { c.el.dataset.status = v.key; setText(c.title, (c.title.title = name)); const sub = []; - if (inst.title && inst.title !== inst.name) sub.push(inst.name); + if (ownTitle(inst)) sub.push(inst.name); if (live && inst.started_at) sub.push(uptime(inst.started_at)); const subtitle = sub.join(' · ') || `编号 ${inst.index}`; const headless = inst.running && inst.window === false && v.key !== 'creating'; setText(c.sub, subtitle + (headless ? ' · 无头' : '') + (inst.diagnostics ? ' · 诊断模式' : '')); c.sub.title = headless ? '命令行开的,没有窗口,可以在这里关机' : ''; - setText(c.cfg, cfgText(inst.config)); + setText(c.cfg, cfgText(inst.config, inst.data_space)); let [pt, tone] = PILL[v.key]; // create's setup: our own job's progress, else what the process doing it (CLI, AUTO-MAS) left in the instance diff --git a/internal/ui/web/js/format.js b/internal/ui/web/js/format.js index 55f740c..200dc2a 100644 --- a/internal/ui/web/js/format.js +++ b/internal/ui/web/js/format.js @@ -14,14 +14,25 @@ export function fmtMem(mb) { return mb % 1024 ? (mb / 1024).toFixed(1) + ' GB' : mb / 1024 + ' GB'; } +// The instance's own title; '' when it has none (a new instance's avd.ini.displayname is its file name mas_N). +export function ownTitle(inst) { + const t = inst && inst.title ? inst.title.trim() : ''; + return t && t !== inst.name ? t : ''; +} + +// 「明日方舟」, or 「emu-0」 when the instance has no title of its own. mas_N is the file name, not shown as a name. +export function defaultName(i) { + return `emu-${i}`; +} + export function label(inst) { if (!inst) return ''; - return inst.title && inst.title.trim() ? inst.title : inst.name; + return ownTitle(inst) || defaultName(inst.index); } -// 「明日方舟」, or 「mas_0」 when the instance has no title of its own. +// 「明日方舟」, or 「emu-0」 when the instance has no title of its own. export function quoted(inst, i) { - return inst ? `「${label(inst)}」` : `「实例 ${i}」`; + return inst ? `「${label(inst)}」` : `「${defaultName(i)}」`; } export function parseTime(s) { @@ -142,9 +153,12 @@ export function stopMethod(m) { return (STOP_METHOD[base] || `已关机(${base})`) + (term ? ',最后强行结束了模拟器进程' : ''); } -// 「内存 6 GB · 6 核 · 60 Hz · 盘 64 GB」 / 「… · 盘自动扩」 (自动扩盘): what each number is, without a hover -export function cfgText(cfg) { +// 「内存 6 GB · 6 核 · 60 Hz · 盘 64 GB · 占 12.3 GB」: what each number is, without a hover. The disk size only for a +// set size (自动扩盘 is the default, nothing to tell); 占 is what the data disk takes on the computer now +// (space.host_bytes, only what was written), left out when unknown. +export function cfgText(cfg, space) { cfg = cfg || {}; - const disk = cfg.data_disk_mode === 'manual' ? `盘 ${cfg.data_partition_gb || '—'} GB` : '盘自动扩'; - return `内存 ${fmtMem(cfg.memory_mb)} · ${cfg.cpu || '—'} 核 · ${cfg.fps || '—'} Hz · ${disk}`; + const disk = cfg.data_disk_mode === 'manual' ? ` · 盘 ${cfg.data_partition_gb || '—'} GB` : ''; + const used = space && space.host_bytes >= 0 ? ` · 占 ${fmtBytes(space.host_bytes)}` : ''; + return `内存 ${fmtMem(cfg.memory_mb)} · ${cfg.cpu || '—'} 核 · ${cfg.fps || '—'} Hz${disk}${used}`; } diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index c6c07ab..592a6eb 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -4,7 +4,7 @@ import { h, icon, btn, ibtn, showError, attr } from './dom.js'; import { api } from './api.js'; import { S, update, subscribe, instance, lastJob } from './store.js'; import { openDialog, toast } from './ui.js'; -import { quoted, label, uptime, fmtBytes } from './format.js'; +import { quoted, label, ownTitle, defaultName, uptime, fmtBytes } from './format.js'; import * as A from './actions.js'; const MEM = [[3072, '3 GB'], [4096, '4 GB'], [5120, '5 GB'], [6144, '6 GB']]; @@ -168,7 +168,7 @@ export function openCreate() { const base = S.list.port_base || 20000; const sync = () => { const i = idx.value === '' ? free[0] : Number(idx.value); - titleHint.textContent = `不填就显示 mas_${i},以后可以随时改`; + titleHint.textContent = `不填就显示「${defaultName(i)}」,以后可以随时改`; idxHint.replaceChildren('编号决定端口,脚本连 ', h('b', { text: `127.0.0.1:${base + 1 + 10 * i}` })); }; idx.addEventListener('change', sync); @@ -214,9 +214,9 @@ export function openSettings(i, field) { const inst0 = instance(i); if (!inst0 || settingsOpen.has(i)) return; const cfg = inst0.config || {}; - const initTitle = inst0.title && inst0.title !== inst0.name ? inst0.title : ''; + const initTitle = ownTitle(inst0); const initDiagnostics = inst0.diagnostics === true; - const title = h('input', { class: 'input', id: 's-title', maxlength: '40', autocomplete: 'off', value: initTitle, placeholder: inst0.name }); + const title = h('input', { class: 'input', id: 's-title', maxlength: '40', autocomplete: 'off', value: initTitle, placeholder: defaultName(i) }); const diagnostics = h('input', { type: 'checkbox', id: 's-diagnostics', checked: initDiagnostics, 'aria-describedby': 's-diagnostics-h' }); const boss = bossKeyField(inst0.boss_key || '', () => dirty()); const runNote = h('div', { class: 'note-box tone-warn', role: 'status' }, icon('info'), h('span', { text: '实例运行中,内存、CPU、帧率、分辨率和数据盘在下次开机生效。诊断设置立即生效。' })); @@ -236,7 +236,7 @@ export function openSettings(i, field) { body: h('div', { class: 'dlg-body' }, runNote, h('h3', { class: 'sec-title', text: '可以修改' }), h('div', { class: 'fields' }, - inputRow('名称', title, hint(`不填就显示 ${inst0.name}`)), + inputRow('名称', title, hint(`不填就显示「${defaultName(i)}」`)), segRow('内存', 'memory', MEM, cfg.memory_mb), segRow('CPU', 'cpu', CPU, cfg.cpu), fps.rows, @@ -305,7 +305,6 @@ 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 (r.boss_key_taken) { // saved, but another program has the key: say so (the settings page marks it too) return { tone: 'warn', title: '已保存,但老板键用不了', text: `${r.instance.boss_key} 被别的程序占了,在设置里换一个。` }; diff --git a/internal/ui/web/js/panels.js b/internal/ui/web/js/panels.js index 378abb7..4b3d6aa 100644 --- a/internal/ui/web/js/panels.js +++ b/internal/ui/web/js/panels.js @@ -2,10 +2,10 @@ import { h, icon, btn, keyed, showError, setText, attr } from './dom.js'; import { api } from './api.js'; -import { S, update, subscribe, instance, jobsSorted, runningCount, checkTarget, rootBlocked } from './store.js'; +import { S, update, subscribe, instance, jobsSorted, runningCount } 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, gles32Text } from './format.js'; +import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, defaultName, parseTime, pct, stripInst, stopMethod, diskInfoText, diskTrimText, diskCompactText, playStoreText, gles32Text } from './format.js'; import * as A from './actions.js'; const advice = (text) => h('div', { class: 'advice' }, icon('bulb'), h('span', { text })); @@ -13,48 +13,22 @@ 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); -} +// The check is the host's and the root's, the same for every instance (host memory is not checked). // 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 || key !== activeKey) refreshAgain = true; + if (refresh) refreshAgain = true; return; } - activeKey = requestedKey = key; S.checking = true; S.checkError = null; update(); try { - 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(); - } + S.check = await api.get('/api/check' + (refresh ? '?refresh=1' : '')); + S.checkedAt = Date.now(); } catch (e) { - if (key === targetKey(checkTarget())) S.checkError = e; + S.checkError = e; } S.checking = false; update(); @@ -70,7 +44,7 @@ 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'; - const text = tone === 'err' && c.root_ready === true ? '内存不足' : { err: '不能开机', warn: '有提示', ok: '就绪' }[tone]; + const text = { err: '不能开机', warn: '有提示', ok: '就绪' }[tone]; return { tone, text, blockers, hints, n: items.length }; } @@ -152,12 +126,6 @@ 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 @@ -173,9 +141,7 @@ 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' }, - 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), + body: h('div', { class: 'dlg-body' }, sum, box, list, passed, comps, store.el), foot: h('footer', { class: 'dlg-foot' }, when, folderBtn('btn btn-ghost'), again), }); checkCtl = ctl; @@ -199,15 +165,6 @@ 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(); @@ -236,7 +193,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]; @@ -275,7 +232,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, targetName()]) : ''; + const sig = c && !c.ready ? JSON.stringify([c.items, c.components]) : ''; if (el.sig === sig) return; el.sig = sig; el.hidden = !sig; @@ -286,7 +243,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: rootBlocked() ? '这个目录现在还不能开机' : `${targetName()}暂不满足启动条件` }), + h('h2', { text: '这个目录现在还不能开机' }), first && h('p', { text: first.reason }), first && first.advice && advice(first.id === 'components' ? '请点下面的「补全组件」打开许可协议并下载,或运行 tools\\modavd.exe components fetch。' @@ -375,7 +332,7 @@ function updateJob(r, j) { // components / collect-info belong to the root (index -1): no instance name const rootJob = j.index < 0; const inst = rootJob ? null : instance(j.index); - const name = rootJob ? '' : inst ? label(inst) : `实例 ${j.index}`; + const name = rootJob ? '' : inst ? label(inst) : defaultName(j.index); if (r.name !== name) { r.name = name; r.title.replaceChildren(...[rootJob ? KIND[j.kind] || j.kind : `${KIND[j.kind] || j.kind}「${name}」`, diff --git a/internal/ui/web/js/store.js b/internal/ui/web/js/store.js index 9442288..f74693f 100644 --- a/internal/ui/web/js/store.js +++ b/internal/ui/web/js/store.js @@ -8,8 +8,6 @@ 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, @@ -65,20 +63,9 @@ export function lastJob(i, running) { return best; } -// 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. +// A blocking check failed: no instance can boot from this root (the check is the same for every instance). 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 }; + return !!S.check && S.check.ready === false; } export function runningCount() { diff --git a/internal/update/decouple_test.go b/internal/update/decouple_test.go new file mode 100644 index 0000000..b323f68 --- /dev/null +++ b/internal/update/decouple_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package update + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "modavd/internal/testutil" +) + +// From 0.41 the version is not 0..x: the package's manifest says the emulator build in "build", and +// the package check compares sdk\emulator's Pkg.BuildId with that, not with the version. +func TestVerifyPackageVersionNotTiedToBuild(t *testing.T) { + put := func(t *testing.T, version string, build any, noManifest bool) layout { + t.Helper() + l := layout{root: testutil.TempDir(t)} + files := pkgFiles(40, nil) + for rel, body := range files { + p := l.pkgAt(rel) + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if noManifest { + return l + } + var m map[string]any + json.Unmarshal(manifestFor(40, files), &m) + m["version"] = version + if build == nil { + delete(m, "build") + } else { + m["build"] = build + } + b, _ := json.Marshal(m) + os.WriteFile(l.pkgAt(manifestName), b, 0o644) + return l + } + for _, tc := range []struct { + name string + version string + build any + target Version + tag string + wantErr string + }{ + {"new version, emulator mas-40", "v0.41.0", 40, Version{0, 41, 0}, "v0.41.0", ""}, + {"version far from the build", "v0.57.3", 40, Version{0, 57, 3}, "v0.57.3", ""}, + {"manifest names another build", "v0.41.0", 39, Version{0, 41, 0}, "v0.41.0", "包里模拟器的构建号(40)"}, + {"legacy manifest without build", "v0.40.0", nil, Version{0, 40, 0}, "v0.40.0", ""}, + {"legacy manifest without build, other version", "v0.41.0", nil, Version{0, 41, 0}, "v0.41.0", "包里模拟器的构建号(40)"}, + {"build not positive", "v0.41.0", 0, Version{0, 41, 0}, "v0.41.0", "构建号(0)不对"}, + {"version differs from the target", "v0.41.0", 40, Version{0, 42, 0}, "v0.42.0", "清单的版本"}, + } { + t.Run(tc.name, func(t *testing.T) { + l := put(t, tc.version, tc.build, false) + _, err := l.verifyPackage(tc.target, tc.tag) + if tc.wantErr == "" && err != nil || tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + if tc.wantErr == "" { + if err := l.buildStage(tc.target); err != nil { + t.Fatalf("buildStage: %v", err) + } + } + }) + } + t.Run("legacy package without manifest", func(t *testing.T) { + l := put(t, "", nil, true) + if _, err := l.verifyPackage(Version{0, 40, 0}, "v37.2.10-mas40"); err != nil { + t.Fatal(err) + } + if _, err := l.verifyPackage(Version{0, 41, 0}, "v37.2.10-mas41"); err == nil { + t.Fatal("mas-40 emulator accepted as mas-41") + } + }) +} diff --git a/internal/update/inuse_dll_windows_test.go b/internal/update/inuse_dll_windows_test.go new file mode 100644 index 0000000..984f01d --- /dev/null +++ b/internal/update/inuse_dll_windows_test.go @@ -0,0 +1,126 @@ +//go:build windows + +package update + +import ( + "context" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "modavd/internal/errs" +) + +// loadDLL puts a copy of a small system DLL at path and loads it into this process, the way MAA loads +// mumu-shim\shell\sdk\external_renderer_ipc.dll; freed at the end of the test (or by the returned func). +func loadDLL(t *testing.T, path string) (free func()) { + t.Helper() + b, err := os.ReadFile(filepath.Join(os.Getenv("SystemRoot"), "System32", "msimg32.dll")) + if err != nil { + t.Skipf("no system DLL to load: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } + h, err := syscall.LoadLibrary(path) + if err != nil { + t.Fatalf("LoadLibrary %s: %v", path, err) + } + freed := false + free = func() { + if !freed { + freed = true + syscall.FreeLibrary(h) + } + } + t.Cleanup(free) + return free +} + +func TestFileInUseSeesLoadedDLL(t *testing.T) { + dir := t.TempDir() + dll := filepath.Join(dir, "external_renderer_ipc.dll") + free := loadDLL(t, dll) + if !fileInUse(dll) { + t.Error("a loaded DLL is not in use") + } + free() + if fileInUse(dll) { + t.Error("a freed DLL is still in use") + } + plain := filepath.Join(dir, "plain.txt") + os.WriteFile(plain, []byte("x"), 0o644) + readOnly := filepath.Join(dir, "readonly.txt") + os.WriteFile(readOnly, []byte("x"), 0o444) + for _, p := range []string{plain, readOnly, filepath.Join(dir, "missing.dll")} { + if fileInUse(p) { + t.Errorf("%s counts as in use", p) + } + } + if b, _ := os.ReadFile(plain); string(b) != "x" { + t.Errorf("the check changed the file: %q", b) + } +} + +// TestInstallOverThirdPartyShim: AUTO-MAS copies its own external_renderer_ipc.dll to the same path, so the root's +// file differs from the root manifest. A full package install takes the package's copy (the other one is kept in the +// backup until the install is done) and does not fail. +func TestInstallOverThirdPartyShim(t *testing.T) { + const shim = "mumu-shim/shell/sdk/external_renderer_ipc.dll" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{shim: "shim 36"}) + if err := os.WriteFile(filepath.Join(r.Dir, filepath.FromSlash(shim)), []byte("AUTO-MAS copy"), 0o644); err != nil { + t.Fatal(err) + } + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, map[string]string{shim: "shim 36"}), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("install: %+v %v", st, err) + } + if got := read(t, r, shim); got != "shim 36" { + t.Errorf("shim = %q", got) + } +} + +// TestInstallBlockedByLoadedDLL: MAA holding the MuMu shell (or extras\modavd_extras.dll) loaded stops the install +// before anything is swapped, with a message that says which file and to close MAA; the stage stays installable and +// the next install, once MAA is closed, goes through. +func TestInstallBlockedByLoadedDLL(t *testing.T) { + const shim = "mumu-shim/shell/sdk/external_renderer_ipc.dll" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{shim: "shim 36"}) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, map[string]string{shim: "shim 37"}), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + free := loadDLL(t, filepath.Join(r.Dir, filepath.FromSlash(shim))) + _, err := u.Install(context.Background()) + if errs.CodeOf(err) != errs.Busy || !strings.Contains(err.Error(), `mumu-shim\shell\sdk\external_renderer_ipc.dll`) || + !strings.Contains(err.Error(), "MAA") || !strings.Contains(err.Error(), "什么都没动") { + t.Fatalf("blocked: %v", err) + } + if st := u.Status(); st.Phase != phaseStaged || st.PreparedVersion != "v0.37.0" { + t.Fatalf("after: %+v", st) + } + for rel, want := range map[string]string{"sdk/emulator/emulator.exe": "emulator mas-36", "说明.txt": "ModAVD 0.36.0\n", + "tools/modavd-bridge.ps1": "# bridge 36\n"} { + if got := read(t, r, rel); got != want { + t.Errorf("%s = %q after the blocked install", rel, got) + } + } + if lexists(u.l.previous()) { + t.Error("previous\\ left behind") + } + free() + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("install after MAA closed: %+v %v", st, err) + } + if got := read(t, r, shim); got != "shim 37" { + t.Errorf("shim = %q", got) + } +} diff --git a/internal/update/native_package_darwin.go b/internal/update/native_package_darwin.go index 57773bc..9d93d92 100644 --- a/internal/update/native_package_darwin.go +++ b/internal/update/native_package_darwin.go @@ -81,7 +81,7 @@ func digestBytes(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToSt // Symlinks may contain '..', but every resolution step stays inside its move unit. func validateNativeManifest(m *nativeManifest) (Version, error) { v, ok := ParseVersion(m.Version) - if !ok || m.Schema != 1 || m.Format != nativeFormat || m.Platform != root.CurrentPlatform().PackageSuffix || m.Build != v.Build || len(m.Files) == 0 || len(m.Files) > maxEntries || len(m.Units) > len(nativeAllowedUnits) { + if !ok || m.Schema != 1 || m.Format != nativeFormat || m.Platform != root.CurrentPlatform().PackageSuffix || m.Build <= 0 || len(m.Files) == 0 || len(m.Files) > maxEntries || len(m.Units) > len(nativeAllowedUnits) { return Version{}, packageError("macOS 更新清单格式、平台或版本不正确") } units := map[string]bool{} @@ -482,7 +482,7 @@ func verifyDarwinPackageAtContext(ctx context.Context, m *nativeManifest, target if err := verifyNativeTreeAt(m, at); err != nil { return err } - if err := checkEmulator(at("sdk/emulator"), target); err != nil { + if err := checkEmulator(at("sdk/emulator"), m.Build, target); err != nil { return err } required := append(managerPaths(), "sdk/emulator/"+root.CurrentPlatform().Emulator, "sdk/emulator/qemu/"+root.CurrentPlatform().QemuDir+"/"+root.CurrentPlatform().QEMU, "sdk/emulator/qemu/"+root.CurrentPlatform().QemuDir+"/"+root.CurrentPlatform().QEMUHeadless) diff --git a/internal/update/pkg.go b/internal/update/pkg.go index bf35fb2..0ca12e6 100644 --- a/internal/update/pkg.go +++ b/internal/update/pkg.go @@ -521,17 +521,17 @@ func hashFile(p string) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } -// checkPackageManifest is AUTO-MAS check_package_manifest: version, build and platform match what is being installed. +// checkPackageManifest is AUTO-MAS check_package_manifest: version and platform match what is being installed, and +// "build" (the emulator build, not tied to the version since 0.41) is a positive integer when given. func checkPackageManifest(m *jsonord.Obj, target Version) error { v, ok := ParseVersion(manifestString(m, "version")) if !ok || v != target { return packageError("包里清单的版本(%s)和要装的版本(%s)不一致", displayVersion(manifestString(m, "version")), target.Display()) } if b := m.Get("build"); b.Kind() != jsonord.Null { - n, ok := b.Int() - if !ok || int(n) != target.Build { + if n, ok := b.Int(); !ok || n <= 0 { lit, _ := b.NumberLiteral() - return packageError("包里清单的构建号(%s)和版本 %s 的构建号(%d)不一致", lit, target.Display(), target.Build) + return packageError("包里清单的构建号(%s)不对", lit) } } p, ok := m.Get("platform").Str() @@ -546,9 +546,20 @@ func checkPackageManifest(m *jsonord.Obj, target Version) error { var buildIDRe = regexp.MustCompile(`^mas-([0-9]+)$`) -// checkEmulator is AUTO-MAS check_emulator: the package's sdk\emulator is complete, its Pkg.BuildId matches, and its -// qemu is our own build (it knows the free-page-reporting balloon option). -func checkEmulator(emu string, target Version) error { +// packageBuild is the emulator build the package in pkg\ says it carries: its manifest's "build", else (a legacy +// package without one) the target's minor number, as versions through 0.40 were numbered. +func (l layout) packageBuild(target Version) int { + if m, ok := readManifest(l.pkgAt(manifestName)); ok { + if n, ok := m.Get("build").Int(); ok && n > 0 { + return int(n) + } + } + return target.Minor +} + +// checkEmulator is AUTO-MAS check_emulator: the package's sdk\emulator is complete, its Pkg.BuildId is mas-, +// and its qemu is our own build (it knows the free-page-reporting balloon option). +func checkEmulator(emu string, want int, target Version) error { p := root.CurrentPlatform() qemu := filepath.Join(emu, "qemu", p.QemuDir, p.QEMU) if !isFile(filepath.Join(emu, p.Emulator)) || !isFile(qemu) { @@ -566,8 +577,8 @@ func checkEmulator(emu string, target Version) error { build = m[1] } } - if build != fmt.Sprint(target.Build) { - return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), target.Build) + if build != fmt.Sprint(want) { + return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), want) } if !fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) { return packageError("包里的模拟器不是 ModAVD 的自编版") @@ -631,7 +642,7 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { } verified = "manifest" } - if err := checkEmulator(l.pkgAt("sdk/emulator"), target); err != nil { + if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { return "", err } return verified, nil @@ -639,7 +650,7 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { // buildStage moves pkg\sdk\emulator to .update\stage\emulator. func (l layout) buildStage(target Version) error { - if err := checkEmulator(l.pkgAt("sdk/emulator"), target); err != nil { + if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { return err } if err := removeTree(l.stage()); err != nil { diff --git a/internal/update/state_other.go b/internal/update/state_other.go index 0631eba..90f6af4 100644 --- a/internal/update/state_other.go +++ b/internal/update/state_other.go @@ -6,3 +6,6 @@ import "os" func isReparsePoint(st os.FileInfo) bool { return st.Mode()&os.ModeSymlink != 0 } func removeLink(p string) bool { return os.Remove(p) == nil } + +// fileInUse: a loaded library or an open file never stops a rename or an unlink here. +func fileInUse(string) bool { return false } diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 07daeac..78ae678 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -3,11 +3,28 @@ package update import ( + "errors" "os" "syscall" ) -const fileAttributeReparsePoint = 0x400 +const ( + fileAttributeReparsePoint = 0x400 + errorSharingViolation = syscall.Errno(32) + errorLockViolation = syscall.Errno(33) +) + +// fileInUse: another process holds path so that it can be neither replaced nor deleted, e.g. a DLL MAA has loaded +// (a mapped image refuses write access) or a file opened without sharing. Opened for writing without truncating and +// closed at once; any other error (read-only, missing) is not "in use": the swap reports those itself. +func fileInUse(path string) bool { + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err == nil { + f.Close() + return false + } + return errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) +} func isReparsePoint(st os.FileInfo) bool { sys, ok := st.Sys().(*syscall.Win32FileAttributeData) diff --git a/internal/update/swap.go b/internal/update/swap.go index cbd4005..4c21c01 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -243,6 +243,22 @@ func (l layout) applyRootFiles(p rootPlan) error { return nil } +// rootFileInUse is the first root file the plan replaces or deletes that another process holds (fileInUse), "" when +// none. The running program itself is left out: applyRootFiles renames it away first. +func (l layout) rootFileInUse(p rootPlan) string { + self := selfExe() + for _, rel := range append(append([]string{}, p.replace...), p.delete...) { + dst := l.at(rel) + if inFold(p.created, rel) || (self != "" && strings.EqualFold(dst, self)) || !isFile(dst) { + continue + } + if fileInUse(dst) { + return rel + } + } + return "" +} + // restoreRootFiles undoes applyRootFiles from previous\root. func (l layout) restoreRootFiles(p rootPlan) error { for _, rel := range p.created { @@ -312,6 +328,13 @@ func (u *Updater) swapIn() (string, error) { if err := l.backupRootFiles(plan); err != nil { return "", err } + // A root file another program holds (MAA loads extras\modavd_extras.dll and + // mumu-shim\shell\sdk\external_renderer_ipc.dll) cannot be replaced: found out after the renames, it would leave + // the root half swapped until that program exits. Checked while nothing has been changed yet. + if rel := l.rootFileInUse(plan); rel != "" { + l.backToStaged(st) + return "", swapBlocked("%s 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装", filepath.FromSlash(rel)) + } st.Set("phase", jsonord.NewString(phaseSwapping)) st.Set("plan", plan.value()) st.Set("swap", stepVal(stepBegin)) diff --git a/internal/update/version.go b/internal/update/version.go index 7e70e9d..39884f3 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -11,23 +11,24 @@ import ( "modavd/internal/root" ) -// Version is a ModAVD version: 0.. (the first number is the stage, 0 while in beta). Ordered as -// (Major, Build, Patch). AUTO-MAS update_source.ModAvdVersion. -type Version struct{ Major, Build, Patch int } +// Version is a ModAVD version: 0.. (the first number is the stage, 0 while in beta). Ordered as +// (Major, Minor, Patch). Through 0.40 the minor number was the emulator build (Pkg.BuildId mas-N); later versions +// are numbered on their own and the package says its emulator build in modavd-manifest.json's "build". +type Version struct{ Major, Minor, Patch int } // String is the version's identity, vX.Y.Z: state.json, comparisons, MirrorChyan's current_version. -func (v Version) String() string { return fmt.Sprintf("v%d.%d.%d", v.Major, v.Build, v.Patch) } +func (v Version) String() string { return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) } // Display is X.Y.Z, for people. -func (v Version) Display() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Build, v.Patch) } +func (v Version) Display() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } -// Less orders versions as (Major, Build, Patch). +// Less orders versions as (Major, Minor, Patch). func (v Version) Less(o Version) bool { if v.Major != o.Major { return v.Major < o.Major } - if v.Build != o.Build { - return v.Build < o.Build + if v.Minor != o.Minor { + return v.Minor < o.Minor } return v.Patch < o.Patch } @@ -65,18 +66,10 @@ func displayVersion(text string) string { return text } -// LocalVersion is the installed package's version: the root manifest's when its build matches sdk\emulator's -// Pkg.BuildId (or that is unknown), else v0..0 (sdk\emulator replaced by hand), else unknown. +// LocalVersion is the installed package's version (root.InstalledVersion). func LocalVersion(r *root.Root) (Version, bool) { - build, haveBuild := r.ModBuild() - declared, haveDeclared := manifestVersion(r.Dir) - switch { - case haveDeclared && (!haveBuild || declared.Build == build): - return declared, true - case haveBuild: - return Version{0, build, 0}, true - } - return Version{}, false + major, minor, patch, ok := r.InstalledVersion() + return Version{major, minor, patch}, ok } func manifestVersion(dir string) (Version, bool) { diff --git a/internal/win/mem.go b/internal/win/mem.go new file mode 100644 index 0000000..2ae5992 --- /dev/null +++ b/internal/win/mem.go @@ -0,0 +1,68 @@ +//go:build windows + +package win + +import ( + "syscall" + "unsafe" +) + +var ( + procK32GetProcessMemoryInfo = modkernel32.NewProc("K32GetProcessMemoryInfo") + procGlobalMemoryStatusEx = modkernel32.NewProc("GlobalMemoryStatusEx") +) + +// processMemoryCountersEx is PROCESS_MEMORY_COUNTERS_EX. +type processMemoryCountersEx struct { + cb uint32 + pageFaultCount uint32 + peakWorkingSetSize uintptr + workingSetSize uintptr + quotaPeakPagedPoolUsage uintptr + quotaPagedPoolUsage uintptr + quotaPeakNonPagedPoolUsage uintptr + quotaNonPagedPoolUsage uintptr + pagefileUsage uintptr + peakPagefileUsage uintptr + privateUsage uintptr +} + +// Memory is GetProcessMemoryInfo of p, in bytes: commit is its private commit charge (Task Manager's "Commit size"), +// peakCommit the highest that has been, workingSet what it has in physical memory now. +func (p Process) Memory() (commit, peakCommit, workingSet uint64, err error) { + h, err := p.open(0) + if err != nil { + return 0, 0, 0, err + } + defer syscall.CloseHandle(h) + var c processMemoryCountersEx + c.cb = uint32(unsafe.Sizeof(c)) + if r, _, e := procK32GetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&c)), uintptr(c.cb)); r == 0 { + return 0, 0, 0, e + } + return uint64(c.privateUsage), uint64(c.peakPagefileUsage), uint64(c.workingSetSize), nil +} + +// memoryStatusEx is MEMORYSTATUSEX. +type memoryStatusEx struct { + length uint32 + memoryLoad uint32 + totalPhys uint64 + availPhys uint64 + totalPageFile uint64 + availPageFile uint64 + totalVirtual uint64 + availVirtual uint64 + availExtendedVirtual uint64 +} + +// SystemMemory is GlobalMemoryStatusEx, in bytes: physical memory in all and available, the commit limit (physical +// memory plus page files) and how much of it is committed. +func SystemMemory() (totalPhys, availPhys, commitLimit, committed uint64, err error) { + var s memoryStatusEx + s.length = uint32(unsafe.Sizeof(s)) + if r, _, e := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&s))); r == 0 { + return 0, 0, 0, 0, e + } + return s.totalPhys, s.availPhys, s.totalPageFile, s.totalPageFile - min(s.availPageFile, s.totalPageFile), nil +} diff --git a/release/0.9.0.SHA256SUMS.txt b/release/0.9.0.SHA256SUMS.txt new file mode 100644 index 0000000..c3fc022 --- /dev/null +++ b/release/0.9.0.SHA256SUMS.txt @@ -0,0 +1,3 @@ +5dbdd688052859a8e0b0cea5a0ac77a255ac2df9b3bd4b468fd251734f786259 ModAVD.exe +a0e10bf96063d8abab6a8502b10604cdab59743becdd8b0c29e6c5496d839b4e ModAVDManager-0.9.0-win-x64.zip +bc35ebd7d67759e2bf9bb8519d52027d6321f46758e1bc9d29c08c872c58f62c tools/modavd.exe diff --git a/release/0.9.0.md b/release/0.9.0.md new file mode 100644 index 0000000..6788ab3 --- /dev/null +++ b/release/0.9.0.md @@ -0,0 +1,62 @@ +# ModAVDManager v0.9.0 + +- `ModAVD.exe`:`5dbdd688052859a8e0b0cea5a0ac77a255ac2df9b3bd4b468fd251734f786259` +- `tools/modavd.exe`:`bc35ebd7d67759e2bf9bb8519d52027d6321f46758e1bc9d29c08c872c58f62c` + +随 ModAVD 0.40.1 进包。下面是相对 v0.8.0 的改动。这一版主要是:没起名的实例显示 `emu-N`,不再显示文件名 `mas_N`,卡片上能看到实例占了多少硬盘;公开版本号和模拟器构建号分开;整包更新遇到根目录文件被别的程序(比如 MAA)占着时,换包前就停,不再换到一半;问题包里能看出模拟器占了多少内存。 + +## 变更 + +- 没起名的实例显示 `emu-0`、`emu-1` 这样的名字,不再显示文件名 `mas_0`。新建和设置里名称框的占位、「不填就显示…」提示跟着改。卡片副标题照旧写编号,起了名的照旧带文件名。盘上的 `avd\mas_N.avd`、`mas_N.ini`、日志名都不变,老根目录不用搬。(ModAVDManager#135、ModAVDManager#139) +- 卡片上的规格不再写「盘自动扩」:自动扩盘是默认,不用写;指定了大小的照旧写「盘 N GB」。(ModAVDManager#139) +- 卡片上的规格末尾加上数据盘现在实际占的电脑硬盘,比如「占 12.3 GB」,只算写进去的;读不到就不写。(ModAVDManager#140) +- 电脑检查去掉「检查对象」下拉框:0.7.0 起开机前不再按实例查内存,所有实例的检查结果都一样。检查结果的标题是「暂时不能开机」,顶栏状态去掉「内存不足」。(ModAVDManager#136) +- 版本号和模拟器构建号分开:ModAVD 0.40.1 以后的公开版本号自己往上加,模拟器构建号(`mas-N`)只记在包里 `modavd-manifest.json` 的 `"build"`。管理器认本地版本、装包时核对模拟器都改看 `"build"`;没有 `"build"` 的老包照旧按版本中间那位核对。0.8.0 及更早的管理器还要求版本中间那位等于构建号,所以带它们的根目录要先更新到 0.40.1,以后的版本才能用「更新」装上。开机要求的最低构建号照旧按 `mas-N`。(ModAVDManager#135) +- 开机守护日志 `logs\modavd-bootwatch.log` 在开机后和之后每 10 分钟写一行模拟器的提交内存、工作集和电脑的剩余内存;问题包 `summary.txt` 里每个 qemu-system 进程多一行内存,另加一节「占内存最多的程序」(按提交排前 10 个,同名合并,只写程序名、进程数和内存,不写命令行和路径)。只在 Windows 上有。(ModAVDManager#138) + +## 修复 + +- 整包更新时,如果根目录里要换或要删的文件正被别的程序载入着(比如 MAA 载入的 `extras\modavd_extras.dll`),以前要到换完 `sdk\emulator` 以后替换它时才失败,根目录停在换了一半的状态,直到关掉那个程序才能收尾。现在换包前先逐个试一下:被占着就停,什么都没动,暂存的新版留着,提示是哪个文件、先关掉载入它的程序再装。只在 Windows 上检查。(ModAVDManager#137) + +## 给调用方(AUTO-MAS) + +- `protocol` 还是 2:没有删命令、参数和 `--json` 字段,没有新的错误码和退出码。调用方不用改。 +- `modavd list` 的人读表格:没起名的实例,名称列写 `emu-N`(以前写文件名 `mas_N`)。`--json` 的 `name`(`mas_N`)、`title` 不变。(ModAVDManager#135、ModAVDManager#139) +- `update install` 遇到根目录文件被占用时是 `busy`(退出码 5),消息「<文件> 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装」;状态退回「待安装」,关掉那个程序后再 `install` 就行。(ModAVDManager#137) +- 管理器自己的接口 `GET /api/check` 去掉了 `index` 参数和返回里的 `instance_index`、`memory_mb`、`root_ready`;`ready` 为 `false` 就是所有实例都开不了机。命令行 `check --memory` 和 MCP `check_host` 的 `memory_mb` 照旧接受、不起作用。(ModAVDManager#136) + +## 下载与核对 + +用 Go 1.26.5、`GOTOOLCHAIN=local` 运行仓库里的 `build.ps1`,能编出相同的字节。单独下载的包里还有 Go 的许可原文和文件清单。请用附件 `SHA256SUMS.txt` 核对 zip 和解压出的两个 exe,例如在 PowerShell 里运行 `Get-FileHash .\ModAVDManager-0.9.0-win-x64.zip -Algorithm SHA256`。 + +单独下载的管理器不会自己更新,新版本要手动替换;ModAVD 包里的那份随 ModAVD 整包一起更新。单独使用时请给 `--root` 或设置 `MODAVD_ROOT`,否则会提示找不到 ModAVD 根目录。不要把单独下载的 zip 解压到 ModAVD 根目录,覆盖整包里的文件。 + +## English + +Shipped in ModAVD 0.40.1. Changes since v0.8.0. This release mostly brings: instances without a name of their own show as `emu-N` instead of the file name `mas_N`, and the card shows how much disk each instance takes; the public version number is separate from the emulator build number; a whole-package update stops before swapping anything when another program (such as MAA) holds a root file, instead of stopping halfway; and problem reports show how much memory the emulator takes. + +### Changes + +- Instances without a name of their own show as `emu-0`, `emu-1` and so on instead of the file name `mas_0`. The placeholder and the hint of the name box in the create dialog and the settings follow. The card subtitle still shows the index, and named instances still show the file name. `avd\mas_N.avd`, `mas_N.ini` and the log names on disk do not change, so old roots need no moving. (ModAVDManager#135, ModAVDManager#139) +- The card no longer says "盘自动扩" (disk grows by itself): it is the default and needs no mention; a set size still shows as "盘 N GB". (ModAVDManager#139) +- The card ends with what the data disk takes on the computer's disk now, such as "占 12.3 GB" (takes 12.3 GB), counting only what was written; left out when it cannot be read. (ModAVDManager#140) +- The host check drops its "检查对象" (checked for) drop-down: since 0.7.0 nothing is checked per instance before a start, so every instance gets the same result. The result title is "暂时不能开机" (cannot start yet), and the top bar no longer has an "out of memory" state. (ModAVDManager#136) +- The version number is separate from the emulator build number: after ModAVD 0.40.1 the public version goes up on its own, and the emulator build (`mas-N`) is recorded only as `"build"` in the package's `modavd-manifest.json`. The manager reads `"build"` to tell the local version and to check the emulator when installing a package; old packages without `"build"` are checked against the middle number of the version as before. Managers 0.8.0 and older still require the middle number to equal the build, so a root with one of them has to update to 0.40.1 first before later versions install through "更新" (update). The minimum build needed to start is still in `mas-N`. (ModAVDManager#135) +- The boot watcher log `logs\modavd-bootwatch.log` gets a line after boot and every 10 minutes with the emulator's commit and working set and the computer's free memory; the problem report's `summary.txt` gets a memory line for each qemu-system process and a section with the 10 programs that commit the most memory (same names merged; only program names, process counts and memory, no command lines or paths). Windows only. (ModAVDManager#138) + +### Fixes + +- When a root file to be replaced or deleted was loaded by another program during a whole-package update (for example `extras\modavd_extras.dll` loaded by MAA), the update used to fail only when replacing it after `sdk\emulator` had been swapped, leaving the root half swapped until that program was closed. Now each file is tried before the swap: if one is held, the update stops with nothing changed, keeps the staged package, and says which file it is and to close the program that loaded it first. Windows only. (ModAVDManager#137) + +### For callers (AUTO-MAS) + +- `protocol` is still 2: no command, option or `--json` field was removed, and there are no new error codes or exit codes. Callers need no change. +- The human-readable table of `modavd list` shows `emu-N` in the name column for instances without a name of their own (the file name `mas_N` before). `name` (`mas_N`) and `title` in `--json` do not change. (ModAVDManager#135, ModAVDManager#139) +- `update install` returns `busy` (exit code 5) when a root file is held by another program, with the message " 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装" (the file is in use by another program, probably something like MAA that loaded it; nothing was changed; close it first); the state goes back to staged, and `install` works again once that program is closed. (ModAVDManager#137) +- The manager's own `GET /api/check` drops the `index` parameter and `instance_index`, `memory_mb` and `root_ready` from its result; `ready` being `false` means no instance can start. `check --memory` on the command line and `memory_mb` of MCP `check_host` are still accepted and have no effect. (ModAVDManager#136) + +### Download and verification + +Running the repository's `build.ps1` with Go 1.26.5 and `GOTOOLCHAIN=local` builds the same bytes. The standalone package also carries Go's license text and the file list. Check the zip and the two extracted exe files with the attached `SHA256SUMS.txt`, for example `Get-FileHash .\ModAVDManager-0.9.0-win-x64.zip -Algorithm SHA256` in PowerShell. + +The standalone manager does not update itself; replace it by hand for a new version. The copy in the ModAVD package is updated with the whole ModAVD package. When using it standalone, pass `--root` or set `MODAVD_ROOT`, or it reports that it cannot find the ModAVD root. Do not extract the standalone zip into a ModAVD root over the package's files. diff --git a/rsrc_windows_amd64.syso b/rsrc_windows_amd64.syso index 029beb4..555b9cb 100644 Binary files a/rsrc_windows_amd64.syso and b/rsrc_windows_amd64.syso differ diff --git a/scripts/test_card_cfg.mjs b/scripts/test_card_cfg.mjs new file mode 100644 index 0000000..88eb159 --- /dev/null +++ b/scripts/test_card_cfg.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const source = await readFile(new URL('../internal/ui/web/js/format.js', import.meta.url), 'utf8'); +const { cfgText } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + +const cfg = { memory_mb: 4096, cpu: 4, fps: 30, data_disk_mode: 'auto', data_partition_gb: 256 }; + +test('the card shows what the data disk takes on the computer', () => { + assert.equal(cfgText(cfg, { host_bytes: 12.34 * 2 ** 30 }), '内存 4 GB · 4 核 · 30 Hz · 占 12.3 GB'); + assert.equal(cfgText({ ...cfg, data_disk_mode: 'manual', data_partition_gb: 64 }, { host_bytes: 512 * 2 ** 20 }), + '内存 4 GB · 4 核 · 30 Hz · 盘 64 GB · 占 512 MB'); +}); + +test('an unknown size is left out, not shown as a dash', () => { + assert.equal(cfgText(cfg, { host_bytes: -1 }), '内存 4 GB · 4 核 · 30 Hz'); + assert.equal(cfgText(cfg, null), '内存 4 GB · 4 核 · 30 Hz'); + assert.equal(cfgText(cfg), '内存 4 GB · 4 核 · 30 Hz'); +}); diff --git a/scripts/test_create_cancel.mjs b/scripts/test_create_cancel.mjs index 08d12be..e3ba6f5 100644 --- a/scripts/test_create_cancel.mjs +++ b/scripts/test_create_cancel.mjs @@ -17,7 +17,7 @@ async function harness() { return { id: 'j2', kind: 'stop', state: 'running', index: 3 }; } } }, 'dom.js': Object.fromEntries(['h', 'icon', 'btn', 'ibtn', 'keyed', 'setText', 'attr'].map(k => [k, noop])), - 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'ownTitle', 'defaultName', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), 'ui.js': { toast: noop, toastError: e => { throw e; }, copyText: noop, openMenu: noop }, 'forms.js': { openSettings: noop, openDelete: noop }, 'proxy.js': { openProxy: noop, markOf: noop }, diff --git a/scripts/test_gles32_ui.mjs b/scripts/test_gles32_ui.mjs index 82ae872..7f45182 100644 --- a/scripts/test_gles32_ui.mjs +++ b/scripts/test_gles32_ui.mjs @@ -120,7 +120,7 @@ async function menuFor(inst, jobs = []) { const m = await load('cards.js', { 'api.js': { api: {} }, 'dom.js': dom, - 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'ownTitle', 'defaultName', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), 'ui.js': { toast: noop, toastError: noop, copyText: noop, openMenu: noop }, 'forms.js': { openSettings: noop, openDelete: noop }, 'proxy.js': { openProxy: noop, markOf: noop }, diff --git a/scripts/test_playstore_ui.mjs b/scripts/test_playstore_ui.mjs index 2a7b768..e1ea34a 100644 --- a/scripts/test_playstore_ui.mjs +++ b/scripts/test_playstore_ui.mjs @@ -120,7 +120,7 @@ async function menuFor(inst, jobs = []) { const m = await load('cards.js', { 'api.js': { api: {} }, 'dom.js': dom, - 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'ownTitle', 'defaultName', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), 'ui.js': { toast: noop, toastError: noop, copyText: noop, openMenu: noop }, 'forms.js': { openSettings: noop, openDelete: noop }, 'proxy.js': { openProxy: noop, markOf: noop },