diff --git a/README.en.md b/README.en.md index aceb42b..806f689 100644 --- a/README.en.md +++ b/README.en.md @@ -73,7 +73,7 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] | `check [--memory M]` | Computer check before starting | | `report-config [--no-clipboard]` | Copy the computer configuration and the game info from the running instances (read-only) | | `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | Collect a problem report and zip it (read-only). Ctrl+C before ZIP completion returns `canceled`, deletes the unfinished ZIP and removes staging; `--keep-stage` preserves staging and includes its path in the error | -| `components [status]`, `components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]` | View or fetch the Google components (adb and the system image); does what `补全组件.bat` (fetches the Google components) does (see "Fetching the Google components") | +| `components [status]`, `components source [auto\|googledownloads_cn\|tencent\|google]`, `components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]` | View, choose, or fetch the Google components (adb and the system image; see "Fetching the Google components") | | `window show\|hide`, `install `, `shell `, `adb -- ` | Window, install an APK, guest command, adb passthrough | | `update [status\|check\|prepare\|install\|recover] [--automas-version V]` | Update the whole ModAVD package in the root folder, using the update source from "Settings" (`设置`) (see "Updating ModAVD") | | `servers`, `version`, `mcp`, `help [command]` | | @@ -247,7 +247,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis ## Fetching the Google components -The ModAVD release package does not include adb (platform-tools 37.0.1) or the Android 14 Google APIs x86_64 system image (r14, about 1.5 GB, about 4.2 GB unpacked): they belong to Google, are governed by the Android Software Development Kit License Agreement, and cannot be redistributed. `modavd components fetch` downloads them from official channels and installs them into `sdk\`; its rules match the bundled `fetch-components.ps1` (`补全组件.bat`) one for one: +The ModAVD release package does not include adb (platform-tools 37.0.1) or the Android 14 Google APIs x86_64 system image (r14, about 1.5 GB, about 4.2 GB unpacked): they belong to Google, are governed by the Android Software Development Kit License Agreement, and cannot be redistributed. `modavd components fetch` downloads them from official channels and installs them into `sdk\`. The manager's "补全组件" button and this command share the same logic; the first interactive run asks for a download source and remembers it: - First it fetches the full license text from `repository2-3.xml` on a mirror and displays it (stating which mirror it came from and which components will be downloaded), and continues only if you type `y`; otherwise nothing is downloaded and `downloads\` gets no extra files. `--accept-license` skips the prompt, but the full text is still fetched and displayed, and if it cannot be fetched it still exits; with `--json` it is required, otherwise the command always reports `usage` (exit code 2), before it even looks at the root folder and whether or not components are missing (up to 0.3.0 it succeeded when nothing was missing). The license text and the prompt are not affected by `--quiet`. - Mirrors are tried in this order: googledownloads.cn → Tencent Cloud mirror → dl.google.com. When something is to be downloaded, a line "下载源:…" (download source) first says which ones this run uses and why (default / `--mirror` / `--mirror-url`). `--mirror` uses only one of them (case-insensitive); `--mirror-url` uses your own directory URL in place of those three (it can be given several times or comma-separated, and the URLs are tried in order). A mirror from which the license manifest could not be fetched is moved to the end when downloading. diff --git a/README.md b/README.md index cb31aac..7b630fc 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] | `check [--memory M]` | 开机前的电脑检查 | | `report-config [--no-clipboard]` | 复制电脑配置和开着实例里的游戏信息(只读) | | `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | 收集问题信息并打包(只读);ZIP 完成前的 Ctrl+C 返回 `canceled`,删除未完成 ZIP 并清理暂存目录;`--keep-stage` 则保留暂存目录并在错误中给出路径 | -| `components [status]`、`components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]` | 查看或补全 Google 组件(adb 和系统镜像),做的是「补全组件.bat」做的事(见「补全 Google 组件」) | +| `components [status]`、`components source [auto\|googledownloads_cn\|tencent\|google]`、`components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]` | 查看、选择或补全 Google 组件(adb 和系统镜像;见「补全 Google 组件」) | | `window show\|hide`、`install `、`shell <命令>`、`adb -- <参数>` | 窗口、装 APK、客体命令、adb 透传 | | `update [status\|check\|prepare\|install\|recover] [--automas-version V]` | 更新根目录里的整个 ModAVD 包,按「设置」里的更新源(见「更新 ModAVD」) | | `servers`、`version`、`mcp`、`help [命令]` | | @@ -249,7 +249,7 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd ## 补全 Google 组件 -ModAVD 的发布包里没有 adb(平台工具 37.0.1)和 Android 14 Google APIs x86_64 系统镜像(r14,约 1.5 GB,解压后约 4.2 GB):它们属于 Google,受《Android 软件开发套件许可协议》约束,不能再分发。`modavd components fetch` 从官方渠道下载、装进 `sdk\`,规则和随包的 `fetch-components.ps1`(「补全组件.bat」)一条一条对上: +ModAVD 的发布包里没有 adb(平台工具 37.0.1)和 Android 14 Google APIs x86_64 系统镜像(r14,约 1.5 GB,解压后约 4.2 GB):它们属于 Google,受《Android 软件开发套件许可协议》约束,不能再分发。`modavd components fetch` 从官方渠道下载、装进 `sdk\`。管理器检测页的「补全组件」按钮和这个命令共用同一套逻辑;首次交互运行会询问下载源并记住选择: - 先从镜像取 `repository2-3.xml` 里的许可协议全文并显示(注明取自哪个镜像、要下载哪些组件),输入 `y` 才继续,否则什么也不下,`downloads\` 里也不多出文件。`--accept-license` 不询问,但协议全文照样取回、照样显示,取不到照样退出;`--json` 时必须给,否则一律报 `usage`(退出码 2):在看根目录之前就报,组件齐不齐都一样(0.3.0 及以前组件齐的时候会成功)。协议和提问不受 `--quiet` 影响。 - 镜像依次试 googledownloads.cn → 腾讯云镜像 → dl.google.com,要下载时先打一行「下载源:…」说明这次用哪些、为什么(默认 / `--mirror` / `--mirror-url`);`--mirror` 只用其中一个(不分大小写),`--mirror-url` 用自己的目录地址代替这三个(可写多次或用逗号隔开,按顺序试)。取不到许可协议清单的镜像,下载时排到最后。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 64581a8..c4103b5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -148,6 +148,7 @@ type deps struct { resolveRoot func(flag string) (*root.Root, error) serveMCP func(ctx context.Context, in io.Reader, out io.Writer, opt mcp.Options) error fetchComponents func(ctx context.Context, r *root.Root, opt components.Options) (*components.Result, error) + stdinTerminal func(io.Reader) bool } func defaultDeps() deps { @@ -162,9 +163,17 @@ func defaultDeps() deps { resolveRoot: root.Resolve, serveMCP: mcp.Serve, fetchComponents: components.Fetch, + stdinTerminal: readerIsTerminal, } } +// readerIsTerminal: stdin is someone at a console (components fetch may ask which mirror to use), not a pipe, a +// file or NUL. Platform part in terminal_*.go. +func readerIsTerminal(r io.Reader) bool { + f, ok := r.(*os.File) + return ok && f != nil && fileIsTerminal(f) +} + // Main runs one command and returns the process exit code (errs.ExitCode, see the package comment). func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if len(args) > 0 { @@ -242,6 +251,9 @@ func newLogger(w io.Writer, g globals) *logx.Logger { // run is Main without the process-wide setup. func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, d deps) int { + if d.stdinTerminal == nil { + d.stdinTerminal = readerIsTerminal + } inv, perr := parseArgs(args) log := newLogger(stderr, inv.globals) out := &output{stdout: stdout, stderr: stderr, json: inv.json, raw: inv.cmd != nil && inv.cmd.raw, log: log} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index a666aed..d6d2e87 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1,8 +1,10 @@ package cli import ( + "bufio" "fmt" "io" + "os" "path/filepath" "runtime" "slices" @@ -193,7 +195,7 @@ func init() { run: runCheck, human: humanOf(formatCheck), }, { - name: "report-config", args: "[--no-clipboard]", summary: "复制电脑配置和实例里装的游戏(同「复制配置信息.bat」)", + name: "report-config", args: "[--no-clipboard]", summary: "复制电脑配置和实例里装的游戏", details: "电脑、模拟器、硬件加速和开着的实例里装的游戏,显示出来并复制到剪贴板(--no-clipboard 不复制)。" + "只读:只在实例开着、它的 adb server 已经在监听时按 adb 主机协议去问,不起 adb server、不调用 adb.exe、不碰 5037;" + "不带用户名、电脑名和完整路径。目前只支持 Windows。", @@ -202,7 +204,7 @@ func init() { }, { name: "collect-info", args: "[--out-dir 目录] [--stage-dir 目录] [--keep-stage] [--no-explorer]", - summary: "收集问题信息,打成 zip(同「收集问题信息.bat」)", + summary: "收集问题信息,打成 zip", details: "打成「魔改AVD问题信息-<时间>.zip」,默认放桌面,再打开资源管理器选中它(--no-explorer 不打开)。" + "--stage-dir 是暂存目录的父目录(默认 %TEMP%),--keep-stage 保留暂存目录。每一节单独收,出错写进 summary.txt 接着收下一节。" + "只读:不收磁盘镜像,不收 AUTO-MAS 的配置和账号数据,不起 adb server、不碰 5037。目前只支持 Windows。", @@ -210,17 +212,18 @@ func init() { run: runCollectInfo, human: humanCollectInfo, }, { - name: "components", args: "[status] | fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]", + name: "components", args: "[status] | source [auto|googledownloads_cn|tencent|google] | fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]", summary: "查看或补全 Google 组件(adb 和系统镜像)", - details: "status(默认)查看组件,和 check 里的组件一样。fetch 做随包「补全组件.bat」做的事:" + + details: "status(默认)查看组件,和 check 里的组件一样。fetch 补全缺少的组件:" + "先从镜像取《Android 软件开发套件许可协议》全文并显示,输入 y 同意才继续,否则什么也不下;" + "再依次试 googledownloads.cn、腾讯云镜像、dl.google.com,下载缺少的 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下)," + "校验后装进 sdk\\。已经齐全的跳过;不完整的旧目录改名为 <目录>.incomplete-<时间> 留着,不删。" + - "--json 时必须给 --accept-license。只有 Windows 有组件清单,darwin、linux 返回 unsupported。" + + "不带参数时默认查看组件;source 查看或修改记住的下载源(auto、googledownloads_cn、tencent、google)。" + + "--json 时必须给 --accept-license(仅 fetch)。只有 Windows 有组件清单,darwin、linux 返回 unsupported。" + "退出码:没同意许可协议是 2(license_declined),路径有中文、空间不够、不是魔改 AVD 的文件夹是 4," + "正在补全是 5,下不成是 9(components_unavailable),下载或解压后没通过校验也是 9(components_rejected)。", flags: fetchFlags, - posNames: []string{"status 或 fetch"}, maxPos: 1, + posNames: []string{"status、source 或 fetch", "下载源"}, maxPos: 2, final: true, run: runComponents, human: formatComponents, }, @@ -440,6 +443,15 @@ type ComponentsStatusResult struct { Components []emu.ComponentStatus `json:"components"` } +// ComponentsSourceResult is `components source`: the saved per-user choice. +// Source is auto, one of the built-in mirror IDs, or custom when URLs are set. +type ComponentsSourceResult struct { + Source string `json:"source"` + Mirror string `json:"mirror"` + MirrorURLs []string `json:"mirror_urls"` + Configured bool `json:"configured"` +} + // ---- request building (pure, tested directly) -------------------------------------------------------------------- func buildCreateRequest(inv *invocation) (emu.CreateRequest, error) { @@ -880,8 +892,39 @@ func runComponents(c *cmdCtx) (any, error) { if len(c.inv.pos) > 0 { action = strings.ToLower(c.inv.pos[0]) } - if action != "fetch" && action != "status" { - return nil, errs.Newf(errs.Usage, "只能是 status 或 fetch,收到「%s」", c.inv.pos[0]).WithAdvice(findCommand("components").usageAdvice()) + if action != "fetch" && action != "status" && action != "source" { + return nil, errs.Newf(errs.Usage, "只能是 status、source 或 fetch,收到「%s」", c.inv.pos[0]).WithAdvice(findCommand("components").usageAdvice()) + } + if action == "source" { + for _, f := range fetchFlags { + if c.inv.isSet(f.name) { + return nil, errs.Newf(errs.Usage, "%s 只用于 components fetch", strings.Join([]string{"--" + f.name}, "、")). + WithAdvice(findCommand("components").usageAdvice()) + } + } + if len(c.inv.pos) > 2 { + return nil, errs.New(errs.Usage, "components source 最多带一个下载源").WithAdvice(findCommand("components").usageAdvice()) + } + if len(c.inv.pos) == 1 { + return componentsSourceResult(components.LoadSourceSettings()), nil + } + choice := strings.ToLower(strings.TrimSpace(c.inv.pos[1])) + if choice == "auto" { + choice = "" + } + if choice != "" { + if _, err := components.ChooseMirrors(choice, nil); err != nil { + return nil, err + } + } + s, err := components.SaveSourceSettings(choice, nil) + if err != nil { + return nil, err + } + return componentsSourceResult(s), nil + } + if len(c.inv.pos) > 1 { + return nil, errs.Newf(errs.Usage, "components %s 只接受一个参数", action).WithAdvice(findCommand("components").usageAdvice()) } if action == "status" { var given []string @@ -900,13 +943,30 @@ func runComponents(c *cmdCtx) (any, error) { } return &ComponentsStatusResult{Root: r.Dir, Components: emu.ComponentStatuses(r.Components())}, nil } + var ( + r *root.Root + err error + ) + // Legacy releases kept the choice in \downloads\source.txt. Read it + // once before the first interactive fetch, then use the per-user setting. + if !c.inv.isSet("mirror") && !c.inv.isSet("mirror-url") && !c.inv.json && !c.inv.boolOpt("accept-license") && c.deps.stdinTerminal(c.stdin) { + r, err = c.deps.resolveRoot(c.inv.root) + if err != nil { + return nil, err + } + if _, _, err := components.ImportLegacySource(r.Dir); err != nil { + return nil, err + } + } opt, err := fetchOptions(c) if err != nil { return nil, err } - r, err := c.deps.resolveRoot(c.inv.root) - if err != nil { - return nil, err + if r == nil { + r, err = c.deps.resolveRoot(c.inv.root) + if err != nil { + return nil, err + } } fetch := c.deps.fetchComponents if fetch == nil { @@ -919,16 +979,52 @@ func runComponents(c *cmdCtx) (any, error) { return result, nil } +func componentsSourceResult(s components.SourceSettings) *ComponentsSourceResult { + source := "auto" + if len(s.MirrorURLs) > 0 { + source = "custom" + } else if strings.TrimSpace(s.Mirror) != "" { + source = strings.ToLower(strings.TrimSpace(s.Mirror)) + } + urls := append([]string{}, s.MirrorURLs...) + return &ComponentsSourceResult{Source: source, Mirror: s.Mirror, MirrorURLs: urls, Configured: s.Set} +} + // fetchOptions checks every option of components fetch (before the root is looked up) and wires the output: the // script's messages and progress lines go to the log, progress at most every 2 s like update; the license text and // the question are written whatever the log level (raw on stderr, or one log line each with --json). func fetchOptions(c *cmdCtx) (components.Options, error) { inv := c.inv + input := c.stdin + if input == nil { + input = os.Stdin + } o := components.Options{ - AcceptLicense: inv.boolOpt("accept-license"), JSON: inv.json, Input: c.stdin, + AcceptLicense: inv.boolOpt("accept-license"), JSON: inv.json, Input: input, KeepDownloads: inv.boolOpt("keep-downloads"), Mirror: inv.strOpt("mirror"), MirrorURLs: inv.strsOpt("mirror-url"), Log: c.log, } + // --mirror / --mirror-url win. Otherwise the saved per-user source; with none saved, someone at a console is + // asked once (and the answer saved), while --accept-license (automation), --json and a stdin that is not a + // console (pipe, file, NUL) are never asked: they use the default order and save nothing. + if !inv.isSet("mirror") && !inv.isSet("mirror-url") { + saved := components.LoadSourceSettings() + if !saved.Set && !o.AcceptLicense && !o.JSON && c.deps.stdinTerminal(input) { + buffered := bufio.NewReader(input) + choice, err := askComponentSource(c, buffered) + if err != nil { + return o, err + } + // Keep the reader that may already have buffered the answer to the + // subsequent license question. + o.Input = buffered + saved = choice + } + if saved.Set { + o.Mirror, o.MirrorURLs = saved.Mirror, append([]string(nil), saved.MirrorURLs...) + o.SourceOrigin = components.SourceFromSettings + } + } for _, t := range []struct { name string d *time.Duration @@ -981,6 +1077,31 @@ func fetchOptions(c *cmdCtx) (components.Options, error) { return o, nil } +func askComponentSource(c *cmdCtx, input io.Reader) (components.SourceSettings, error) { + fmt.Fprintln(c.stderr, "组件下载源还没有设置,请选择:") + fmt.Fprintln(c.stderr, " 1. 自动(googledownloads_cn、tencent、google,按顺序尝试)") + fmt.Fprintln(c.stderr, " 2. googledownloads_cn") + fmt.Fprintln(c.stderr, " 3. tencent") + fmt.Fprintln(c.stderr, " 4. google") + fmt.Fprint(c.stderr, "选择 [1]:") + line, err := bufio.NewReader(input).ReadString('\n') + if err == io.EOF { + // EOF without an interactive answer means automatic order for this run; + // do not persist a choice inferred from a closed pipe. + return components.SourceSettings{}, nil + } + choice := strings.TrimSpace(line) + if choice == "" || choice == "1" { + return components.SaveSourceSettings("", nil) + } + ids := map[string]string{"2": "googledownloads_cn", "3": "tencent", "4": "google"} + mirror, ok := ids[choice] + if !ok { + return components.SourceSettings{}, errs.Newf(errs.InvalidArgument, "组件下载源编号无效:%s", choice) + } + return components.SaveSourceSettings(mirror, nil) +} + func runWindow(c *cmdCtx) (any, error) { i, err := parseIndex(c.inv.pos[0]) if err != nil { diff --git a/internal/cli/components_test.go b/internal/cli/components_test.go index b26bfe0..050aa35 100644 --- a/internal/cli/components_test.go +++ b/internal/cli/components_test.go @@ -3,6 +3,7 @@ package cli import ( + "bufio" "context" "encoding/json" "io" @@ -209,21 +210,27 @@ func licenseServer(t *testing.T) (*httptest.Server, *int32) { // root is looked up or any request is made, also when the root lacks nothing (the fetch itself would then succeed // without asking): the same command line gets the same answer whatever the root holds. func TestComponentsFetchJSONNeedsAcceptLicense(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) srv, n := licenseServer(t) h := newHarness() + h.terminal = true // only --json keeps the source menu away + h.stdin = "4\n" calls := h.useRoot(componentsRoot(t)) fetched := false h.fetch = func(context.Context, *root.Root, components.Options) (*components.Result, error) { fetched = true return &components.Result{}, nil // a root that lacks nothing } - res := h.run(t, nil, "--json", "components", "fetch", "--mirror-url", srv.URL) + res := h.run(t, nil, "--json", "components", "fetch") e := envelope(t, res.stdout)["error"].(map[string]any) if res.code != 2 || e["code"] != errs.Usage || !strings.Contains(e["message"].(string), "--accept-license") || atomic.LoadInt32(n) != 0 || *calls != 0 || fetched { t.Fatalf("exit %d %v, %d requests, root looked up %d times, fetched %v", res.code, e, atomic.LoadInt32(n), *calls, fetched) } + if strings.Contains(res.stderr, "组件下载源还没有设置") || components.LoadSourceSettings().Set { + t.Fatalf("--json asked for a source or saved one: %q", res.stderr) + } // without --json the question is asked instead, so nothing is refused here if res := h.run(t, nil, "components", "fetch", "--mirror-url", srv.URL); res.code != 0 || !fetched { t.Fatalf("without --json: exit %d, fetched %v", res.code, fetched) @@ -268,3 +275,145 @@ func TestComponentsFetchQuestion(t *testing.T) { t.Fatal("license not shown with --json") } } + +func TestComponentsSourceCommand(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + h := newHarness() + res := h.run(t, nil, "--json", "components", "source") + if res.code != 0 { + t.Fatalf("initial source: %d %s", res.code, res.stderr) + } + data := envelope(t, res.stdout)["data"].(map[string]any) + if urls, ok := data["mirror_urls"].([]any); data["source"] != "auto" || data["configured"] != false || !ok || len(urls) != 0 { + t.Fatalf("initial data: %+v", data) + } + res = h.run(t, nil, "components", "source", "tencent") + if res.code != 0 || !strings.Contains(res.stdout, "tencent") { + t.Fatalf("set source: %d %q %q", res.code, res.stdout, res.stderr) + } + res = h.run(t, nil, "--json", "components", "source") + data = envelope(t, res.stdout)["data"].(map[string]any) + if data["source"] != "tencent" || data["mirror"] != "tencent" || data["configured"] != true { + t.Fatalf("saved data: %+v", data) + } + res = h.run(t, nil, "components", "source", "auto") + if res.code != 0 { + t.Fatalf("set auto: %d %q", res.code, res.stderr) + } +} + +func TestComponentsFetchUsesSavedSource(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + h := newHarness() + h.useRoot(componentsRoot(t)) + var got components.Options + h.fetch = func(_ context.Context, _ *root.Root, o components.Options) (*components.Result, error) { + got = o + return &components.Result{}, nil + } + h.terminal = true + h.stdin = "3\n" + if res := h.run(t, nil, "--quiet", "components", "fetch"); res.code != 0 { + t.Fatalf("first fetch: %d %q", res.code, res.stderr) + } + if got.Mirror != "tencent" || len(got.MirrorURLs) != 0 { + t.Fatalf("selected source: mirror=%q urls=%v", got.Mirror, got.MirrorURLs) + } + // The saved setting is reused without consuming another answer. + h.stdin = "" + if res := h.run(t, nil, "--quiet", "components", "fetch"); res.code != 0 { + t.Fatalf("saved fetch: %d %q", res.code, res.stderr) + } + if got.Mirror != "tencent" { + t.Fatalf("saved source: %q", got.Mirror) + } +} + +// stdin that is not a console (a pipe, a file, NUL) never gets the source menu, also with nothing saved: a piped y +// answers the license question as it did before the menu existed, the default order is used and nothing is saved. +func TestComponentsFetchPipeNeverAsksForSource(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + h := newHarness() + h.useRoot(componentsRoot(t)) + h.stdin = "y\n" + var got components.Options + var answer string + h.fetch = func(_ context.Context, _ *root.Root, o components.Options) (*components.Result, error) { + got = o + answer, _ = bufio.NewReader(o.Input).ReadString('\n') + return &components.Result{}, nil + } + res := h.run(t, nil, "components", "fetch") + if res.code != 0 || strings.Contains(res.stderr, "组件下载源还没有设置") || answer != "y\n" { + t.Fatalf("piped y: exit %d, license answer %q, stderr %q", res.code, answer, res.stderr) + } + if got.Mirror != "" || len(got.MirrorURLs) != 0 || got.SourceOrigin != "" || components.LoadSourceSettings().Set { + t.Fatalf("pipe chose or saved a source: %+v saved=%+v", got, components.LoadSourceSettings()) + } +} + +// --accept-license (automation) never asks either, even at a console with nothing saved, and saves nothing; with +// --json too. +func TestComponentsFetchAutomationDoesNotAskOrSave(t *testing.T) { + lad := t.TempDir() + t.Setenv("LOCALAPPDATA", lad) + h := newHarness() + h.useRoot(componentsRoot(t)) + h.terminal = true + h.fetch = func(_ context.Context, _ *root.Root, o components.Options) (*components.Result, error) { + if o.Mirror != "" || len(o.MirrorURLs) != 0 { + t.Errorf("automation used a source: %+v", o) + } + return &components.Result{}, nil + } + for _, args := range [][]string{{"components", "fetch", "--accept-license"}, {"--json", "components", "fetch", "--accept-license"}} { + h.stdin = "4\n" + res := h.run(t, nil, args...) + if res.code != 0 || strings.Contains(res.stderr, "组件下载源还没有设置") { + t.Fatalf("%q: exit=%d stderr=%q", args, res.code, res.stderr) + } + } + if _, err := os.Stat(filepath.Join(lad, "ModAVD", "components-settings.json")); err == nil { + t.Fatal("automation saved source") + } +} + +// At a console: Enter takes the default and remembers it (no second question); end of input (Ctrl+Z) takes the +// default for this run only; an unknown number is an error and saves nothing. +func TestComponentsFetchSourceMenuAnswers(t *testing.T) { + for _, c := range []struct { + name, stdin string + code int + saved bool + }{ + {"enter", "\n", 0, true}, + {"eof", "", 0, false}, + {"bad number", "9\n", 2, false}, + } { + t.Run(c.name, func(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + h := newHarness() + h.useRoot(componentsRoot(t)) + h.terminal = true + h.stdin = c.stdin + var got components.Options + h.fetch = func(_ context.Context, _ *root.Root, o components.Options) (*components.Result, error) { + got = o + return &components.Result{}, nil + } + res := h.run(t, nil, "components", "fetch") + s := components.LoadSourceSettings() + if res.code != c.code || !strings.Contains(res.stderr, "组件下载源还没有设置") || s.Set != c.saved || s.Mirror != "" || + got.Mirror != "" { + t.Fatalf("exit %d (want %d), saved %+v (want set=%v), options %+v, stderr %q", res.code, c.code, s, c.saved, got, res.stderr) + } + if c.saved { + h.stdin = "" + if res := h.run(t, nil, "components", "fetch"); res.code != 0 || strings.Contains(res.stderr, "组件下载源还没有设置") || + got.SourceOrigin != components.SourceFromSettings { + t.Fatalf("second run: exit %d, origin %q, stderr %q", res.code, got.SourceOrigin, res.stderr) + } + } + }) + } +} diff --git a/internal/cli/format.go b/internal/cli/format.go index b1195e1..6d1a6dd 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -555,6 +555,18 @@ func componentLine(c emu.ComponentStatus) string { func formatComponents(w io.Writer, data any) { switch r := data.(type) { + case *ComponentsSourceResult: + label := r.Source + if label == "custom" { + label = "自定义:" + strings.Join(r.MirrorURLs, "、") + } else if label == "auto" { + label = "自动" + } + if r.Configured { + fmt.Fprintf(w, "组件下载源:%s(已保存)\n", label) + } else { + fmt.Fprintf(w, "组件下载源:%s(默认)\n", label) + } case *ComponentsStatusResult: fmt.Fprintf(w, "根目录:%s\n", r.Root) for _, c := range r.Components { diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go new file mode 100644 index 0000000..1f37f6a --- /dev/null +++ b/internal/cli/main_test.go @@ -0,0 +1,11 @@ +package cli + +import ( + "os" + "testing" + + "modavd/internal/testutil" +) + +// No test here touches the real per-user directories (component download source, update settings, running emulators). +func TestMain(m *testing.M) { os.Exit(testutil.RunWithTempUserDirs(m)) } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 7202814..3dd0b37 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -263,6 +263,7 @@ type harness struct { resolve func(string) (*root.Root, error) fetch func(ctx context.Context, r *root.Root, opt components.Options) (*components.Result, error) stdin string + terminal bool // stdin is a console (components fetch may ask for a source); false like a pipe } type result struct { @@ -288,6 +289,7 @@ func (h *harness) run(t *testing.T, ctx context.Context, args ...string) result resolveRoot: h.resolve, serveMCP: h.mcpCall, fetchComponents: h.fetch, + stdinTerminal: func(io.Reader) bool { return h.terminal }, } if d.resolveRoot == nil { d.resolveRoot = func(string) (*root.Root, error) { return nil, errs.New(errs.NotFound, "没有根目录") } diff --git a/internal/cli/terminal_other.go b/internal/cli/terminal_other.go new file mode 100644 index 0000000..d333968 --- /dev/null +++ b/internal/cli/terminal_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package cli + +import "os" + +// fileIsTerminal: a character device other than /dev/null (a tty). Without x/term this is the closest portable test; +// components fetch, the only user, only runs on Windows for now. +func fileIsTerminal(f *os.File) bool { + st, err := f.Stat() + if err != nil || st.Mode()&os.ModeCharDevice == 0 { + return false + } + null, err := os.Stat(os.DevNull) + return err != nil || !os.SameFile(st, null) +} diff --git a/internal/cli/terminal_test.go b/internal/cli/terminal_test.go new file mode 100644 index 0000000..f9636b4 --- /dev/null +++ b/internal/cli/terminal_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// NUL (/dev/null), a pipe, a file and a reader that is no file are never a console: components fetch must not ask +// which mirror to use when stdin is one of them (`echo y | modavd components fetch`, `< NUL`). +func TestStdinNotAConsole(t *testing.T) { + null, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer null.Close() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + p := filepath.Join(t.TempDir(), "answers.txt") + if err := os.WriteFile(p, []byte("y\n"), 0o644); err != nil { + t.Fatal(err) + } + file, err := os.Open(p) + if err != nil { + t.Fatal(err) + } + defer file.Close() + for name, f := range map[string]*os.File{"null": null, "pipe": r, "file": file} { + if readerIsTerminal(f) { + t.Errorf("%s taken for a console", name) + } + } + if readerIsTerminal(strings.NewReader("y\n")) || readerIsTerminal(nil) { + t.Error("a reader that is no file taken for a console") + } +} diff --git a/internal/cli/terminal_windows.go b/internal/cli/terminal_windows.go new file mode 100644 index 0000000..e95a55c --- /dev/null +++ b/internal/cli/terminal_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package cli + +import ( + "os" + "syscall" +) + +// fileIsTerminal: GetConsoleMode only succeeds on a console input handle. NUL is a character device too (Stat says +// ModeCharDevice), so the mode bits alone would take `< NUL` for a console. +func fileIsTerminal(f *os.File) bool { + var mode uint32 + return syscall.GetConsoleMode(syscall.Handle(f.Fd()), &mode) == nil +} diff --git a/internal/components/fetch.go b/internal/components/fetch.go index 6d8e77f..a4206f1 100644 --- a/internal/components/fetch.go +++ b/internal/components/fetch.go @@ -14,14 +14,18 @@ package components import ( "bufio" "context" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "runtime" "strings" "time" + "unicode" "modavd/internal/buildinfo" "modavd/internal/download" @@ -50,6 +54,33 @@ var ( diskFree = win.DiskFree ) +// License returns the Android SDK license text and the mirror it came from, read from the mirrors of source the +// way a fetch with that source reads it (an empty source is the default order). It only reads the repository +// manifest; it does not touch a ModAVD root or install any component. The manager shows this text and passes its +// SHA-256 as Options.LicenseSHA256 to the fetch the user agreed to. +func License(ctx context.Context, source SourceSettings) (text, from string, err error) { + if fetchGOOS != "windows" { + return "", "", errs.New(errs.Unsupported, "这个系统还没有组件清单").WithAdvice("目前只有 Windows 能补全组件") + } + o := Options{} + if err := o.CheckTimeouts(); err != nil { + return "", "", err + } + o.Log = logx.Nop() + mirrors, err := ChooseMirrors(source.Mirror, source.MirrorURLs) + if err != nil { + return "", "", err + } + f := &fetcher{o: o, mirrors: mirrors} + return f.license(ctx) +} + +// LicenseSHA256 is the hex SHA-256 of a license text, as Options.LicenseSHA256 wants it. +func LicenseSHA256(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:]) +} + // Defaults and limits of the three timeouts (the script's -ConnectTimeoutSec, -StallTimeoutSec, -ManifestTimeoutSec). const ( DefaultConnectTimeout = download.DefaultConnectTimeout @@ -75,6 +106,13 @@ type Options struct { // built-in list, tried in order (each entry may hold several, comma-separated), and win over Mirror. Mirror string MirrorURLs []string + // LicenseSHA256, when set, is the SHA-256 (hex) of the license text the user agreed to in the manager (with + // AcceptLicense): the fetch stops with errs.ComponentsRejected before saving or downloading anything when the + // mirror now serves another text. + LicenseSHA256 string + // SourceOrigin says where Mirror / MirrorURLs were chosen, for the 下载源 line: SourceFromSettings, + // SourceFromManager, or "" for the command line (an option, or the default). + SourceOrigin string // ConnectTimeout bounds connect, TLS handshake and response headers; StallTimeout a read that brings nothing; // ManifestTimeout the whole license manifest. Zero is the default (20, 30, 40 s). ConnectTimeout, StallTimeout, ManifestTimeout time.Duration @@ -167,12 +205,15 @@ func ChooseMirrors(mirror string, urls []string) ([]Mirror, error) { if u == "" { continue } - if l := strings.ToLower(u); !strings.HasPrefix(l, "http://") && !strings.HasPrefix(l, "https://") { - return nil, errs.Newf(errs.InvalidArgument, "--mirror-url 要写 http:// 或 https:// 开头的目录地址,收到「%s」", u) + if err := validateMirrorURL(u); err != nil { + return nil, err } if !strings.HasSuffix(u, "/") { u += "/" } + if len(custom) == maxMirrorURLs { + return nil, errs.Newf(errs.InvalidArgument, "自定义下载地址最多 %d 个", maxMirrorURLs) + } custom = append(custom, Mirror{ID: "custom", Name: "自定义镜像(" + u + ")", Base: u}) } } @@ -182,23 +223,79 @@ func ChooseMirrors(mirror string, urls []string) ([]Mirror, error) { return custom, nil } -// sourceLine says, before anything is downloaded, where it comes from and why: the built-in mirrors in order (the -// default), the one --mirror named, or the --mirror-url addresses. +// Limits of the custom addresses (--mirror-url, the saved setting, the manager's 自定义地址). The zips are checked +// against the SHA-1 and size built into this program whichever mirror they come from; these only keep the +// addresses plain directory URLs. +const ( + maxMirrorURLs = 16 + maxMirrorURLLength = 2048 +) + +// validateMirrorURL accepts an http(s) directory address with a host and nothing else: no user name or password +// (they would end up in logs\fetch-components.log), no ?query or #fragment (the file names are appended to it), no +// white space. A rejected address with a user name is not repeated in the message. +func validateMirrorURL(raw string) error { + if len(raw) > maxMirrorURLLength { + return errs.Newf(errs.InvalidArgument, "自定义下载地址太长(最多 %d 个字符)", maxMirrorURLLength) + } + u, err := url.Parse(raw) + if err == nil && u.User != nil { + return errs.New(errs.InvalidArgument, "自定义下载地址里不能带用户名或密码"). + WithAdvice("需要登录的镜像请改用不带账号的地址") + } + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Opaque != "" || u.Hostname() == "" || + strings.ContainsAny(raw, "?#") || strings.IndexFunc(raw, unicode.IsSpace) >= 0 { + return errs.Newf(errs.InvalidArgument, "自定义下载地址要写 http:// 或 https:// 开头、带主机名的目录地址,不能带 ? 或 # 后面的部分,也不能有空格,收到「%s」", raw) + } + return nil +} + +// Where the source of a fetch was chosen (Options.SourceOrigin). Empty is the command line: --mirror / --mirror-url +// when one was given, else the default order. +const ( + SourceFromSettings = "settings" // the saved per-user setting (components source, 设置 → 组件下载源) + SourceFromManager = "manager" // picked in the manager's license dialog +) + +// sourceLine says, before anything is downloaded, where it comes from and why: the built-in mirrors in order, only +// one of them, or custom addresses; and whether that was the default, a command-line option, the saved setting or +// the manager's choice (ModAVD#120). func sourceLine(o Options, mirrors []Mirror) string { - names := make([]string, len(mirrors)) - for i, m := range mirrors { - names[i] = m.Name + names := strings.Join(mirrorNames(mirrors), "、") + mirror := strings.TrimSpace(o.Mirror) + var why string + switch o.SourceOrigin { + case SourceFromSettings: + why = "保存的设置" + case SourceFromManager: + why = "管理器里选的" + default: + if len(o.MirrorURLs) > 0 { + why = "--mirror-url" + } else if mirror != "" { + why = "--mirror " + mirror + } else { + why = "默认,还没有设置过" + } } switch { case len(o.MirrorURLs) > 0: - return "下载源:" + strings.Join(names, "、") + "(--mirror-url),按顺序试" - case strings.TrimSpace(o.Mirror) != "": - return "下载源:只用" + strings.Join(names, "、") + "(--mirror " + strings.TrimSpace(o.Mirror) + ")" + return "下载源:" + names + "(" + why + "),按顺序试" + case mirror != "": + return "下载源:只用" + names + "(" + why + ")" default: - return "下载源:自动,依次试" + strings.Join(names, "、") + ",前一个不通或校验不对就换下一个;只用其中一个时加 --mirror googledownloads_cn / tencent / google" + return "下载源:自动(" + why + "),依次试" + names + ",前一个不通或校验不对就换下一个;只用其中一个时加 --mirror googledownloads_cn / tencent / google" } } +func mirrorNames(mirrors []Mirror) []string { + names := make([]string, len(mirrors)) + for i, m := range mirrors { + names[i] = m.Name + } + return names +} + // JSONNeedsLicense is the errs.Usage error of a fetch with JSON but without AcceptLicense: nobody can answer the // license question then. The CLI gives it before looking for the root; Fetch gives it again before going online. func JSONNeedsLicense() *errs.Error { @@ -274,7 +371,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) { } if !isRoot { return nil, errs.Newf(errs.NotReady, `这里没有 sdk\emulator\emulator.exe,不像是魔改 AVD 的文件夹:%s`, r.Dir). - WithAdvice("请把 Release 压缩包完整解压后,在解压出来的文件夹里双击「补全组件.bat」") + WithAdvice("请把 Release 压缩包完整解压后,在管理器里点「补全组件」,或运行 tools\\modavd.exe components fetch") } if r.LocalSDKRoot() != "" { // check and components status look at that SDK; fetch only ever writes this root's sdk\. @@ -325,6 +422,13 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) { if err != nil { return nil, err } + if want := strings.TrimSpace(f.o.LicenseSHA256); want != "" { + if got := LicenseSHA256(text); !strings.EqualFold(want, got) { + f.note("管理器里同意的许可协议 SHA-256 " + want + ",这次取到的是 " + got) + return nil, errs.New(errs.ComponentsRejected, "下载源这次给的许可协议和你在管理器里同意的那份不一样,什么也没有下载"). + WithAdvice("请重新点「补全组件」,看过新的许可协议后再决定") + } + } res.License, res.LicenseSource = text, from if err := f.consent(text, from, todo); err != nil { return nil, err @@ -470,7 +574,11 @@ func (f *fetcher) consent(text, from string, todo []root.SDKComponent) error { f.note(shown) f.note(terms) if f.o.AcceptLicense { - f.say("已通过 --accept-license 同意许可协议。") + if f.o.LicenseSHA256 != "" { + f.say("已在管理器里同意许可协议。") + } else { + f.say("已通过 --accept-license 同意许可协议。") + } } else { f.notice("是否同意这份许可协议?输入 y 并回车表示同意,直接回车或输入其他内容则退出:") in := f.o.Input diff --git a/internal/components/fetch_windows_test.go b/internal/components/fetch_windows_test.go index 375e61b..08058da 100644 --- a/internal/components/fetch_windows_test.go +++ b/internal/components/fetch_windows_test.go @@ -797,6 +797,47 @@ func TestFetchJSONNeedsAcceptLicense(t *testing.T) { } } +func TestFetchRejectsChangedLicenseHashBeforeDownload(t *testing.T) { + pt := ptZip(t, adbBody) + setup(t, ptSpec(pt)) + m := newMirror(t, map[string][]byte{"pt.zip": pt}) + r := tempRoot(t) + o := opts(m) + o.LicenseSHA256 = strings.Repeat("0", 64) + out := fetch(t, nil, r, o) + wantCode(t, out.err, errs.ComponentsRejected) + if !strings.Contains(errs.As(out.err).Advice, "补全组件") || out.said("同意许可协议") { + t.Errorf("error %+v, said %q", errs.As(out.err), out.infos) + } + if len(m.zipReqs()) != 0 || exists(downloadsDir(r)) { + t.Fatalf("hash mismatch downloaded files: zip requests=%d downloads=%v", len(m.zipReqs()), exists(downloadsDir(r))) + } +} + +// The manager's consent is to the text its dialog showed, read by License from the source chosen there: with that +// text's SHA-256 the fetch goes on and says the consent was given in the manager (not --accept-license) and where +// the source was chosen. +func TestFetchWithManagerLicenseHash(t *testing.T) { + pt := ptZip(t, adbBody) + setup(t, ptSpec(pt)) + m := newMirror(t, map[string][]byte{"pt.zip": pt}) + text, from, err := License(context.Background(), SourceSettings{MirrorURLs: []string{m.url()}}) + if err != nil || text != testLicense || from != "自定义镜像("+m.url()+")" { + t.Fatalf("License: %q from %q, %v", text, from, err) + } + r := tempRoot(t) + o := opts(m) + o.LicenseSHA256 = strings.ToUpper(LicenseSHA256(text)) + o.SourceOrigin = SourceFromManager + out := fetch(t, nil, r, o) + if out.err != nil { + t.Fatal(out.err) + } + if !out.said("已在管理器里同意许可协议。") || out.said("--accept-license 同意") || !out.said("(管理器里选的),按顺序试") { + t.Errorf("said %q", out.infos) + } +} + // TestFetchUsesProxy: every request of a fetch (manifest and zip) really goes through the proxy hostproxy picks, here // one named by HTTP_PROXY. net/http reads the proxy variables once per process, so the fetch runs in a child // (TestFetchUsesProxyChild) whose first request is that fetch's. diff --git a/internal/components/main_test.go b/internal/components/main_test.go new file mode 100644 index 0000000..5932560 --- /dev/null +++ b/internal/components/main_test.go @@ -0,0 +1,11 @@ +package components + +import ( + "os" + "testing" + + "modavd/internal/testutil" +) + +// No test here touches the real per-user directories (component download source, update settings, running emulators). +func TestMain(m *testing.M) { os.Exit(testutil.RunWithTempUserDirs(m)) } diff --git a/internal/components/settings.go b/internal/components/settings.go new file mode 100644 index 0000000..453be66 --- /dev/null +++ b/internal/components/settings.go @@ -0,0 +1,155 @@ +package components + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "modavd/internal/errs" + "modavd/internal/root" +) + +// SourceSettings is the component download source selected by the user. An +// empty Mirror means automatic order; MirrorURLs, when non-empty, are custom +// HTTP(S) bases and take precedence over Mirror. +type SourceSettings struct { + Mirror string + MirrorURLs []string + // Set distinguishes a saved automatic choice from a missing settings file. + // It is intentionally not serialized. + Set bool `json:"-"` +} + +type sourceSettingsFile struct { + Mirror string `json:"mirror,omitempty"` + MirrorURLs []string `json:"mirror_urls,omitempty"` +} + +// sourceSettingsPath is kept as a variable so tests can point it at a +// temporary file. Production uses the same per-user directory as update +// settings, but a separate file so the two settings cannot overwrite one +// another. +var sourceSettingsPath = func() string { + var base string + if root.CurrentPlatform().Windows() { + base = os.Getenv("LOCALAPPDATA") + if base == "" { + base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") + } + } else { + base, _ = os.UserConfigDir() + if base == "" { + return "" + } + } + return filepath.Join(base, "ModAVD", "components-settings.json") +} + +// LoadSourceSettings reads the per-user component source. Missing, malformed, +// or unsupported files are treated as no saved choice (automatic order). +func LoadSourceSettings() SourceSettings { + s := SourceSettings{} + path := sourceSettingsPath() + if path == "" { + return s + } + b, err := os.ReadFile(path) + if err != nil { + return s + } + var f sourceSettingsFile + if json.Unmarshal(b, &f) != nil { + return s + } + if err := validateSource(f.Mirror, f.MirrorURLs); err != nil { + return s + } + s.Mirror, s.MirrorURLs, s.Set = strings.TrimSpace(f.Mirror), append([]string(nil), f.MirrorURLs...), true + return s +} + +// SaveSourceSettings validates and atomically writes a source choice. An empty +// Mirror with no URLs is a saved automatic choice; this is different from a +// missing file because interactive fetch should not ask again. +func SaveSourceSettings(mirror string, urls []string) (SourceSettings, error) { + mirror = strings.TrimSpace(mirror) + urls = normalizeURLs(urls) + if err := validateSource(mirror, urls); err != nil { + return SourceSettings{}, err + } + s := SourceSettings{Mirror: mirror, MirrorURLs: urls, Set: true} + f := sourceSettingsFile{Mirror: mirror, MirrorURLs: urls} + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败") + } + path := sourceSettingsPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败: "+err.Error()) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败: "+err.Error()) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败: "+err.Error()) + } + return s, nil +} + +func normalizeURLs(urls []string) []string { + var out []string + for _, raw := range urls { + for _, u := range strings.Split(raw, ",") { + u = strings.TrimSpace(u) + if u != "" { + if !strings.HasSuffix(u, "/") { + u += "/" + } + out = append(out, u) + } + } + } + return out +} + +func validateSource(mirror string, urls []string) error { + if _, err := ChooseMirrors(mirror, urls); err != nil { + return err + } + return nil +} + +// ImportLegacySource migrates the old per-root downloads\source.txt choice +// into the per-user settings. Existing settings always win. The file is left +// in place so an interrupted migration cannot destroy user data; subsequent +// runs use the saved setting and no longer consult it. +func ImportLegacySource(rootDir string) (SourceSettings, bool, error) { + current := LoadSourceSettings() + if current.Set || strings.TrimSpace(rootDir) == "" { + return current, false, nil + } + p := filepath.Join(rootDir, "downloads", "source.txt") + b, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return current, false, nil + } + return current, false, errs.Wrap(errs.Internal, err, "读取旧的组件下载源失败") + } + choice := strings.TrimSpace(string(b)) + if strings.EqualFold(choice, "auto") { + choice = "" + } + if _, err := SaveSourceSettings(choice, nil); err != nil { + // Old, unpublished builds could write arbitrary text here. Treat it as + // no saved choice instead of turning a fetch into a usage error. + if errs.CodeOf(err) == errs.InvalidArgument { + return current, false, nil + } + return current, false, err + } + return LoadSourceSettings(), true, nil +} diff --git a/internal/components/settings_test.go b/internal/components/settings_test.go new file mode 100644 index 0000000..4eb87f2 --- /dev/null +++ b/internal/components/settings_test.go @@ -0,0 +1,116 @@ +package components + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSourceSettingsRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "components-settings.json") + old := sourceSettingsPath + sourceSettingsPath = func() string { return path } + t.Cleanup(func() { sourceSettingsPath = old }) + if got := LoadSourceSettings(); got.Set || got.Mirror != "" || len(got.MirrorURLs) != 0 { + t.Fatalf("missing settings = %+v", got) + } + want, err := SaveSourceSettings("tencent", nil) + if err != nil || !want.Set || want.Mirror != "tencent" { + t.Fatalf("save = %+v, %v", want, err) + } + if got := LoadSourceSettings(); !got.Set || got.Mirror != "tencent" { + t.Fatalf("load = %+v", got) + } + if _, err := SaveSourceSettings("", []string{"https://mirror.example/repo"}); err != nil { + t.Fatal(err) + } + got := LoadSourceSettings() + if !got.Set || got.Mirror != "" || len(got.MirrorURLs) != 1 || got.MirrorURLs[0] != "https://mirror.example/repo/" { + t.Fatalf("custom load = %+v", got) + } + if mode := fileMode(t, path); mode.Perm() == 0 { + t.Fatalf("settings mode is empty: %o", mode.Perm()) + } +} + +func TestSourceSettingsRejectBadMirror(t *testing.T) { + path := filepath.Join(t.TempDir(), "components-settings.json") + old := sourceSettingsPath + sourceSettingsPath = func() string { return path } + t.Cleanup(func() { sourceSettingsPath = old }) + if _, err := SaveSourceSettings("aliyun", nil); err == nil { + t.Fatal("unknown mirror accepted") + } + if _, err := SaveSourceSettings("", []string{"ftp://example/"}); err == nil { + t.Fatal("bad URL accepted") + } + for _, u := range []string{"https://", "http://", "https://user:pw@host/x", "https://host/x?q=1#f", "https://host/ x"} { + if _, err := SaveSourceSettings("", []string{u}); err == nil { + t.Fatalf("bad URL accepted: %q", u) + } + } +} + +func TestTamperedSourceSettingsFallBack(t *testing.T) { + path := filepath.Join(t.TempDir(), "components-settings.json") + old := sourceSettingsPath + sourceSettingsPath = func() string { return path } + t.Cleanup(func() { sourceSettingsPath = old }) + for _, body := range []string{`{"mirror":"aliyun"}`, `{"mirror_urls":["file:///C:/Windows/"]}`, `{"mirror_urls":["ftp://x/"]}`, `{"mirror_urls":[""]}`, "not json"} { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got := LoadSourceSettings() + if got.Set || got.Mirror != "" || len(got.MirrorURLs) != 0 { + t.Errorf("%q loaded as %+v", body, got) + } + } +} + +func TestLegacyUnknownSourceIgnored(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "components-settings.json") + old := sourceSettingsPath + sourceSettingsPath = func() string { return path } + t.Cleanup(func() { sourceSettingsPath = old }) + if err := os.MkdirAll(filepath.Join(dir, "root", "downloads"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "root", "downloads", "source.txt"), []byte("https://x.invalid/\r\n"), 0o600); err != nil { + t.Fatal(err) + } + got, migrated, err := ImportLegacySource(filepath.Join(dir, "root")) + if err != nil || migrated || got.Set { + t.Fatalf("unknown legacy source = %+v migrated=%v err=%v", got, migrated, err) + } +} + +func TestImportLegacySource(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "components-settings.json") + old := sourceSettingsPath + sourceSettingsPath = func() string { return path } + t.Cleanup(func() { sourceSettingsPath = old }) + if err := os.MkdirAll(filepath.Join(dir, "root", "downloads"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "root", "downloads", "source.txt"), []byte("google\r\n"), 0o600); err != nil { + t.Fatal(err) + } + got, migrated, err := ImportLegacySource(filepath.Join(dir, "root")) + if err != nil || !migrated || got.Mirror != "google" || !got.Set { + t.Fatalf("import = %+v, migrated=%v, err=%v", got, migrated, err) + } + if _, migrated, err := ImportLegacySource(filepath.Join(dir, "root")); err != nil || migrated { + t.Fatalf("second import migrated=%v err=%v", migrated, err) + } +} + +func fileMode(t *testing.T, path string) os.FileMode { + t.Helper() + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return st.Mode() +} diff --git a/internal/components/source_test.go b/internal/components/source_test.go index ab0cb3c..4c78de1 100644 --- a/internal/components/source_test.go +++ b/internal/components/source_test.go @@ -1,8 +1,11 @@ package components import ( + "fmt" "strings" "testing" + + "modavd/internal/errs" ) // The fetch says where it downloads from before it starts: the default order, the one --mirror named, or --mirror-url. @@ -43,3 +46,58 @@ func TestSourceLineNamesWhereFromAndWhy(t *testing.T) { }) } } + +// The 下载源 line also says where the choice was made: the saved setting or the manager instead of an option, and +// "默认" when nothing chose (ModAVD#120). +func TestSourceLineSaysWhereTheChoiceWasMade(t *testing.T) { + for _, c := range []struct { + o Options + want string + absent string + }{ + {Options{}, "下载源:自动(默认,还没有设置过),依次试", "保存的设置"}, + {Options{SourceOrigin: SourceFromSettings}, "下载源:自动(保存的设置),依次试", "默认"}, + {Options{Mirror: "tencent", SourceOrigin: SourceFromSettings}, "(保存的设置)", "--mirror"}, + {Options{MirrorURLs: []string{"https://m.example/sdk/"}, SourceOrigin: SourceFromSettings}, "自定义镜像(https://m.example/sdk/)(保存的设置),按顺序试", "--mirror-url"}, + {Options{Mirror: "google", SourceOrigin: SourceFromManager}, "下载源:只用Google 官方(dl.google.com)(管理器里选的)", "--mirror"}, + } { + mirrors, err := ChooseMirrors(c.o.Mirror, c.o.MirrorURLs) + if err != nil { + t.Fatal(err) + } + if got := sourceLine(c.o, mirrors); !strings.Contains(got, c.want) || strings.Contains(got, c.absent) { + t.Errorf("%+v: %q, want %q without %q", c.o, got, c.want, c.absent) + } + } +} + +// A custom address is a plain http(s) directory URL with a host; a user name or password is refused without being +// repeated in the message; at most 16 addresses of at most 2048 characters. +func TestCustomMirrorURLShapes(t *testing.T) { + for _, u := range []string{"https://", "http://", "https:///x", "https://:80/x", "ftp://h/x", "file:///C:/x", "h/x", + "https://h/x?q=1", "https://h/x?", "https://h/x#f", "https://h/x#", "https://h/ x", "https://h/a\tb", "mailto:a@b", + "https://h/" + strings.Repeat("a", 2048)} { + if _, err := ChooseMirrors("", []string{u}); errs.CodeOf(err) != errs.InvalidArgument { + t.Errorf("%q: %v", u, err) + } + } + _, err := ChooseMirrors("", []string{"https://user:secret@h/x"}) + if errs.CodeOf(err) != errs.InvalidArgument || strings.Contains(err.Error(), "secret") { + t.Errorf("user info: %v", err) + } + for _, u := range []string{"https://h/x", "HTTP://H:8080/a/b/", "https://[::1]:9/x", "http://127.0.0.1:1/"} { + if _, err := ChooseMirrors("", []string{u}); err != nil { + t.Errorf("%q refused: %v", u, err) + } + } + many := make([]string, 17) + for i := range many { + many[i] = fmt.Sprintf("https://m%d.example/x/", i) + } + if _, err := ChooseMirrors("", many[:16]); err != nil { + t.Errorf("16 addresses: %v", err) + } + if _, err := ChooseMirrors("", []string{strings.Join(many, ",")}); errs.CodeOf(err) != errs.InvalidArgument { + t.Errorf("17 addresses: %v", err) + } +} diff --git a/internal/emu/check.go b/internal/emu/check.go index ea421e2..6dacb10 100644 --- a/internal/emu/check.go +++ b/internal/emu/check.go @@ -5,7 +5,7 @@ package emu // reason and advice; a boot refuses on the blocking ones that fail. Read only: nothing on the host is changed. // // - path: no non-ASCII characters in the root (emulator and qemu are unreliable there); spaces are fine. Blocking. -// - components: platform tools and the system image (fetched by 补全组件.bat). Blocking. +// - components: platform tools and the system image (fetched by the manager's “补全组件” action or components fetch). Blocking. // - emulator: only our own build from the ModAVD package, with Pkg.BuildId=mas-N >= MinModBuild. Blocking. // - acceleration: emulator -accel-check (WHPX). Blocking. // - memory: available host memory >= this boot's -memory + 1.5 GB. Blocking. @@ -159,7 +159,7 @@ func firstBlockingFailure(items []CheckItem) (CheckItem, bool) { } // precheckError maps a failed blocking item to the error a start returns: components -> the root's NotReady error -// (with the package / 补全组件.bat advice), emulator -> NotReady, the rest -> PrecheckFailed. +// (with the package / manager component-fetch advice), emulator -> NotReady, the rest -> PrecheckFailed. func precheckError(it CheckItem, missingErr func() error) error { switch it.ID { case "components": @@ -266,7 +266,7 @@ func componentsItem(comps []root.ComponentStatus, advice func(missing []string) } } -// missingAdvice is the root's advice for missing components (package / 补全组件.bat). +// missingAdvice is the root's advice for missing components (package / manager component-fetch action). func (m *Manager) missingAdvice(missing []string) string { if e := m.Root.MissingError(missing); e != nil && e.Advice != "" { return e.Advice diff --git a/internal/root/root.go b/internal/root/root.go index 961effb..67d0cec 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -776,8 +776,8 @@ const ( // Advice for missing components (AUTO-MAS FETCH_COMPONENTS_ADVICE / PACKAGE_ADVICE; "回来点「检查」" is AUTO-MAS's // button, here it is the check command). const ( - FetchComponentsAdvice = "双击魔改 AVD 根目录里的「补全组件.bat」:它会先显示《Android 软件开发套件许可协议》,输入 y 同意后" + - "从官方渠道下载 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下),看到「完成」后再运行一次 modavd check" + FetchComponentsAdvice = "在管理器里点「补全组件」,或运行 tools\\modavd.exe components fetch;它会先显示《Android 软件开发套件许可协议》,输入 y 同意后" + + "从官方渠道下载 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下),完成后再检查一次" PackageAdvice = "模拟器要用魔改 AVD 的发布包:从 https://github.com/ModAVD/ModAVD/releases 下载 " + "ModAVD-<版本>-win-x64.zip,整个解压到这个目录" ) diff --git a/internal/root/root_test.go b/internal/root/root_test.go index 4e57ee9..6322084 100644 --- a/internal/root/root_test.go +++ b/internal/root/root_test.go @@ -900,7 +900,7 @@ func TestMissingError(t *testing.T) { if got := r.MissingError([]string{SystemImageName}).Error(); got != "这个目录里缺少:系统镜像 Android 14 Google APIs x86_64。"+FetchComponentsAdvice { t.Fatalf("image only: %s", got) } - if !strings.Contains(FetchComponentsAdvice, "再运行一次 modavd check") || strings.Contains(FetchComponentsAdvice, "点「检查」") { + if !strings.Contains(FetchComponentsAdvice, "完成后再检查一次") || strings.Contains(FetchComponentsAdvice, "补全组件.bat") { t.Fatal("advice wording") } if got := r.MissingError(nil).Error(); !strings.HasPrefix(got, "这个目录里缺少:平台工具(adb)、") { diff --git a/internal/testutil/userdirs.go b/internal/testutil/userdirs.go new file mode 100644 index 0000000..6d56957 --- /dev/null +++ b/internal/testutil/userdirs.go @@ -0,0 +1,37 @@ +package testutil + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// RunWithTempUserDirs runs a package's tests with the per-user directories pointed at a new temporary directory: +// LOCALAPPDATA on Windows, and the directories os.UserConfigDir reads elsewhere. No test of the package then reads or +// writes the real %LOCALAPPDATA%\ModAVD (the component download source, update settings, the affinity lock) or +// sees the emulators running on this computer through %LOCALAPPDATA%\Temp\avd\running, even one that forgets to +// set its own. Use it as: func TestMain(m *testing.M) { os.Exit(testutil.RunWithTempUserDirs(m)) }. +func RunWithTempUserDirs(m *testing.M) int { + dir, err := os.MkdirTemp("", "modavd-userdirs-") + if err != nil { + fmt.Fprintln(os.Stderr, "testutil: no temporary user directory:", err) + return 1 + } + defer removeAll(dir, 2*time.Second, os.RemoveAll) + set := map[string]string{"LOCALAPPDATA": filepath.Join(dir, "LocalAppData")} + if runtime.GOOS != "windows" { + set["XDG_CONFIG_HOME"] = filepath.Join(dir, "config") + set["HOME"] = dir + } + for k, v := range set { + if err := os.MkdirAll(v, 0o755); err != nil { + fmt.Fprintln(os.Stderr, "testutil:", err) + return 1 + } + os.Setenv(k, v) + } + return m.Run() +} diff --git a/internal/ui/API.md b/internal/ui/API.md index 5f25d33..bd1ee88 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -54,7 +54,14 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | GET | `/api/instances/{i}/packages` | | `{"packages":[{"package","name","uid","system"}]}`:实例里装的第三方应用,加上装了的话「Google Play 服务」(`system: true`);`name` 是认得的游戏的中文名和「Google Play 服务」,其余是 `""`。认得的游戏在前,然后按包名排。没开机回 409 `busy` | | GET | `/api/proxy/suggest` | | `{"server","notes"}`:这台电脑的代理设置(同 `modavd proxy --server auto`),换成实例里看到的地址,设置没说是 SOCKS5 时探测协议;`notes` 说取自哪里。没设代理回 502 `proxy_unavailable` | | POST | `/api/proxy/probe` | `{server}` | `{"server","host_addr","kind","udp","udp_error","notes"}`:在这台电脑上探测这个地址:是 SOCKS5 还是 HTTP 代理,SOCKS5 再经它发一个 DNS 查询试 UDP。`server` 换成实例里看到的地址(`127.0.0.1`、`localhost` 记成 `10.0.2.2`),协议换成探测到的,`notes` 说改了什么。连不上回 502 `proxy_unavailable` | -| POST | `/api/open` | `{what: "root"\|"logs"\|"instance", index?}` | `{}`,在资源管理器里打开 | +| POST | `/api/open` | `{what: "root"\|"logs"\|"instance"\|"collect-info", index?, job?}` | `{}`,在资源管理器里打开;`collect-info` 打开那个做完了的收集问题信息任务(`job` 是任务 id)生成的问题包所在的文件夹,不接受页面给的路径 | +| GET | `/api/components` | | 组件状态 | +| GET | `/api/components/license` | `?source=auto\|googledownloads_cn\|tencent\|google\|custom&mirror_urls=…`(可重复;不带时用保存的下载源) | `{"license":"全文","license_source":"镜像来源","license_sha256":"全文的 SHA-256"}`:从这个下载源取的许可协议,和补全时取的是同一份;不下载组件 | +| GET | `/api/components/source` | | 当前组件下载源:`source` 为 `auto`、内置镜像 ID 或 `custom`,以及 `mirror_urls`、`configured` | +| PUT | `/api/components/source` | `{"source":"auto"\|"googledownloads_cn"\|"tencent"\|"google"\|"custom", "mirror_urls":[...]}` | 保存当前用户的组件下载源;自定义地址只接受 HTTP(S) 目录 | +| POST | `/api/components/fetch` | `{"accept_license":true, "license_sha256":"…", "source"?:..., "mirror_urls"?:[...]}`(`accept_license` 必须明确为 `true`;`license_sha256` 是看过的那份许可协议的,取自上面的接口,同一个下载源) | 202 `Job`(`kind` 是 `components`,根任务,`index` 为 `-1`)。不带 `source` / `mirror_urls` 时用保存的下载源。后台取到的许可协议和 `license_sha256` 对不上时,任务以 `components_rejected` 失败,什么也不下 | +| 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/jobs` | | `{"jobs":[Job]}`,新的在前,最多 50 个 | | GET | `/api/jobs/{id}` | | `Job` | @@ -80,7 +87,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," "result":null,"error":null,"warnings":[]} ``` -- `kind`:`start` / `stop` / `install` / `create` / `disk-info` / `disk-trim` / `disk-compact`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。 +- `kind`:`start` / `stop` / `install` / `create` / `components` / `collect-info` / `disk-info` / `disk-trim` / `disk-compact`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。 - `state`:`running` / `done` / `failed` / `canceled`。 - `progress.step`:开机用 emu 的 `launch` / `boot` / `tune` / `ime` / `first_boot` / `vulkan_fix` / `done`;关机(含等被取消的开机停下)是 `stop`,安装是 `install`,这两种做完是 `done`;磁盘任务进行中是 `disk_info` / `disk_trim` / `disk_compact`、做完是 `done`(进行中没有百分比,`total` 为 0,页面显示不定长进度条;做完 `done` = `total` = 1000);装执行桥的每一轮是 `bridge`;新建是 `create`、其中几次开机的上述各步(开机做完是 `setup`)、`create_stop`、`done`,几次开机合成一条不回退的进度。`total` 一律 1000。 - 安装任务的 `result.apk` 是用户上传的文件名,不是服务端的临时路径(临时文件任务结束就删了)。 @@ -89,6 +96,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," `DiskCompactResult` 保留非负的 `freed_bytes`。`before_on_disk_bytes` 和 `after_on_disk_bytes` 是整理前后宿主实际占用,新增的 `on_disk_delta_bytes` 等于后者减前者,正值表示占用增大。`skipped=true` 表示检查通过、预计收益不足,未重写原盘;此时前后占用相等、变化和释放量均为零。结果还包含 `reclaim_threshold_bytes` 和可省略的 `estimated_reclaimable_bytes`,详见 [整理预算与结果契约](../emu/COMPACT.md)。 - 同一实例同时只能有一个任务在跑;第二个回 409 `busy`。**例外**:开机任务进行中可以发关机(和 emu 一致:开机中照样能关)。 - 取消开机:只停止等待,模拟器继续开机,不会被强制结束。 +- `components` 和 `collect-info` 是根目录任务,`index` 为 `-1`;取消会传给诊断后端上下文。 ### 事件流 `/api/events` diff --git a/internal/ui/components_source_test.go b/internal/ui/components_source_test.go new file mode 100644 index 0000000..07ed323 --- /dev/null +++ b/internal/ui/components_source_test.go @@ -0,0 +1,36 @@ +//go:build windows + +package ui + +import ( + "net/http" + "testing" +) + +func TestComponentSourceAPI(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + h := newHarness(t, nil) + var got componentSourceView + expect(t, h.do("GET", "/api/components/source", nil), http.StatusOK, &got) + if got.Source != "auto" || got.Configured { + t.Fatalf("initial source: %+v", got) + } + // no custom address is an empty list, not null + for _, req := range [][2]string{{"GET", ""}, {"PUT", `{"source":"google"}`}} { + var raw map[string]any + var body any + if req[1] != "" { + body = req[1] + } + expect(t, h.do(req[0], "/api/components/source", body), http.StatusOK, &raw) + if urls, ok := raw["mirror_urls"].([]any); !ok || len(urls) != 0 { + t.Fatalf("%s mirror_urls = %#v", req[0], raw["mirror_urls"]) + } + } + expect(t, h.do("PUT", "/api/components/source", `{"source":"custom","mirror_urls":["https://mirror.example/sdk"]}`), http.StatusOK, &got) + if got.Source != "custom" || len(got.MirrorURLs) != 1 || got.MirrorURLs[0] != "https://mirror.example/sdk/" { + t.Fatalf("custom source: %+v", got) + } + expectError(t, h.do("PUT", "/api/components/source", `{"source":"custom"}`), http.StatusBadRequest, "usage") + expectError(t, h.do("PUT", "/api/components/source", `{"source":"ftp"}`), http.StatusBadRequest, "invalid_argument") +} diff --git a/internal/ui/diag_backend.go b/internal/ui/diag_backend.go new file mode 100644 index 0000000..0ac906e --- /dev/null +++ b/internal/ui/diag_backend.go @@ -0,0 +1,129 @@ +package ui + +import ( + "context" + + "modavd/internal/components" + "modavd/internal/emu" + "modavd/internal/errs" + "modavd/internal/logx" + "modavd/internal/report" + "modavd/internal/root" +) + +type realDiagBackend struct { + r *root.Root + log *logx.Logger +} + +var ( + diagComponents = func(r *root.Root) []emu.ComponentStatus { return emu.ComponentStatuses(r.Components()) } + diagLicense = components.License + diagFetch = components.Fetch + diagCollect = report.Collect + diagConfig = report.BuildConfig +) + +func newRealDiagBackend(r *root.Root, log *logx.Logger) diagBackend { + if log == nil { + log = logx.Nop() + } + return &realDiagBackend{r: r, log: log} +} + +func (b *realDiagBackend) Components(ctx context.Context) (*DiagComponentsResult, error) { + if err := ctx.Err(); err != nil { + return nil, errs.Wrap(errs.Canceled, err, "已取消") + } + return &DiagComponentsResult{Root: b.r.Dir, Components: toDiagComponents(diagComponents(b.r))}, nil +} + +func (b *realDiagBackend) License(ctx context.Context, source components.SourceSettings) (*DiagLicenseResult, error) { + text, from, err := diagLicense(ctx, source) + if err != nil { + return nil, err + } + return &DiagLicenseResult{Text: text, Source: from, SHA256: components.LicenseSHA256(text)}, nil +} + +func (b *realDiagBackend) Fetch(ctx context.Context, accept bool, source components.SourceSettings, licenseSHA256 string, progress func(DiagProgress)) (*DiagFetchResult, error) { + if !accept { + return nil, errs.New(errs.LicenseDeclined, "没有同意许可协议,已退出,什么也没有下载") + } + res, err := diagFetch(ctx, b.r, components.Options{ + AcceptLicense: true, + Input: nil, + Mirror: source.Mirror, + MirrorURLs: append([]string(nil), source.MirrorURLs...), + LicenseSHA256: licenseSHA256, + SourceOrigin: components.SourceFromManager, + Log: b.log, + // the license text and the note under it: the user has just read them in the dialog, so they go to the + // file log only, not as messages of the job (logs\fetch-components.log and downloads\ keep them too) + Notice: func(s string) { b.log.Debugf("%s", s) }, + Progress: func(p components.Progress) { + if progress != nil { + progress(diagProgressFromComponent(p)) + } + }, + }) + if err != nil { + return nil, err + } + return &DiagFetchResult{Root: res.Root, License: res.License, LicenseSource: res.LicenseSource, + Components: toDiagFetchComponents(res.Components), Downloads: res.Downloads}, nil +} + +func (b *realDiagBackend) Collect(ctx context.Context, opt DiagCollectOptions, progress func(DiagProgress)) (*DiagCollectResult, error) { + res, err := diagCollect(ctx, b.r, report.CollectOptions{ + OutDir: opt.OutDir, StageDir: opt.StageDir, KeepStage: opt.KeepStage, NoExplorer: true, + Progress: func(msg string) { + if progress != nil { + progress(DiagProgress{Stage: "collect", Message: msg}) + } + }, + }) + if err != nil { + return nil, err + } + return &DiagCollectResult{ZipPath: res.ZipPath, ZipBytes: res.ZipBytes, ZipSize: res.ZipSize, + Entries: res.Entries, Stage: res.Stage, StageKept: res.StageKept, StageError: res.StageError, + Problems: res.Problems, MasFound: res.MasFound, Summary: res.Summary, PrivacyNote: res.PrivacyNote}, nil +} + +func (b *realDiagBackend) ReportConfig(ctx context.Context) (string, error) { + res, err := diagConfig(ctx, b.r, report.ConfigOptions{NoClipboard: true}) + if err != nil { + return "", err + } + return res.Text, nil +} + +func toDiagComponents(in []emu.ComponentStatus) []DiagComponent { + out := make([]DiagComponent, 0, len(in)) + for _, c := range in { + out = append(out, DiagComponent{ID: c.ID, Name: c.Name, Version: c.Version, Build: c.Build, + Installed: c.Installed, Optional: c.Optional, LocalSDK: c.LocalSDK}) + } + return out +} + +func toDiagFetchComponents(in []components.ComponentResult) []DiagFetchComponent { + out := make([]DiagFetchComponent, 0, len(in)) + for _, c := range in { + out = append(out, DiagFetchComponent{ID: c.ID, Name: c.Name, Version: c.Version, + Installed: c.Installed, Downloaded: c.Downloaded, Skipped: c.Skipped, SetAside: c.SetAside}) + } + return out +} + +func diagProgressFromComponent(p components.Progress) DiagProgress { + remaining := 0.0 + if p.Speed > 0 && p.Total > 0 && p.Done <= p.Total { + remaining = float64(p.Total-p.Done) / p.Speed + } + return DiagProgress{Stage: p.Stage, Name: p.Name, Message: p.Message, Done: p.Done, Total: p.Total, + Speed: p.Speed, Remaining: remaining} +} + +var _ diagBackend = (*realDiagBackend)(nil) diff --git a/internal/ui/diag_backend_test.go b/internal/ui/diag_backend_test.go new file mode 100644 index 0000000..2872286 --- /dev/null +++ b/internal/ui/diag_backend_test.go @@ -0,0 +1,82 @@ +package ui + +import ( + "context" + "strings" + "testing" + + "modavd/internal/components" + "modavd/internal/logx" + "modavd/internal/root" +) + +// The real adapter refuses a fetch nobody agreed to before calling components.Fetch. +func TestRealDiagBackendRejectsDeclinedLicense(t *testing.T) { + old := diagFetch + t.Cleanup(func() { diagFetch = old }) + calls := 0 + diagFetch = func(context.Context, *root.Root, components.Options) (*components.Result, error) { + calls++ + return &components.Result{}, nil + } + b := newRealDiagBackend(&root.Root{Dir: `C:\root`}, logx.Nop()) + if _, err := b.Fetch(context.Background(), false, components.SourceSettings{}, fakeHash, nil); err == nil || calls != 0 { + t.Fatalf("declined license: err=%v calls=%d", err, calls) + } +} + +const fakeHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +// It hands components.Fetch the source as given (a built-in mirror, or the custom addresses), the license hash, +// where the source was chosen, and its logger: the license text the dialog already showed only at Debug. +func TestRealDiagBackendPassesSourceAndLicenseHash(t *testing.T) { + old := diagFetch + t.Cleanup(func() { diagFetch = old }) + var got []components.Options + diagFetch = func(_ context.Context, r *root.Root, o components.Options) (*components.Result, error) { + got = append(got, o) + o.Notice("许可协议全文") + o.Log.Infof("下载源:测试") + return &components.Result{Root: r.Dir}, nil + } + var lines []string + log := logx.New(func(level logx.Level, msg string) { lines = append(lines, level.String()+" "+msg) }) + b := newRealDiagBackend(&root.Root{Dir: `C:\root`}, log) + for _, s := range []components.SourceSettings{ + {Mirror: "google", Set: true}, + {MirrorURLs: []string{"https://a.example/x/", "https://b.example/y/"}, Set: true}, + } { + if _, err := b.Fetch(context.Background(), true, s, fakeHash, nil); err != nil { + t.Fatal(err) + } + } + if len(got) != 2 || got[0].Mirror != "google" || len(got[0].MirrorURLs) != 0 || + got[1].Mirror != "" || strings.Join(got[1].MirrorURLs, ",") != "https://a.example/x/,https://b.example/y/" { + t.Fatalf("sources: %+v", got) + } + for _, o := range got { + if !o.AcceptLicense || o.LicenseSHA256 != fakeHash || o.SourceOrigin != components.SourceFromManager || o.Input != nil { + t.Fatalf("options %+v", o) + } + } + if want := "debug 许可协议全文|info 下载源:测试"; !strings.HasPrefix(strings.Join(lines, "|"), want) { + t.Fatalf("log lines %q, want them to start with %q", lines, want) + } +} + +// License answers the text read from the given source with its SHA-256. +func TestRealDiagBackendLicenseHash(t *testing.T) { + old := diagLicense + t.Cleanup(func() { diagLicense = old }) + var got components.SourceSettings + diagLicense = func(_ context.Context, s components.SourceSettings) (string, string, error) { + got = s + return "terms", "mirror", nil + } + b := newRealDiagBackend(&root.Root{Dir: `C:\root`}, logx.Nop()) + res, err := b.License(context.Background(), components.SourceSettings{Mirror: "tencent", Set: true}) + if err != nil || got.Mirror != "tencent" || res.Text != "terms" || res.SHA256 != components.LicenseSHA256("terms") || + len(res.SHA256) != 64 { + t.Fatalf("license %+v %v, source %+v", res, err, got) + } +} diff --git a/internal/ui/diag_jobs_test.go b/internal/ui/diag_jobs_test.go new file mode 100644 index 0000000..d4feccd --- /dev/null +++ b/internal/ui/diag_jobs_test.go @@ -0,0 +1,175 @@ +//go:build windows + +package ui + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "modavd/internal/components" + "modavd/internal/errs" + "modavd/internal/logx" +) + +type fakeDiag struct { + mu sync.Mutex + fetchCalls int + fetchStart chan struct{} + fetchReturn chan struct{} + canceled chan struct{} + zipPath string // what Collect returns (C:\tmp\diag.zip when empty) +} + +// the license fakeDiag shows, and the license_sha256 a fetch after reading it carries +const fakeTerms = "terms" + +var fakeTermsSHA = components.LicenseSHA256(fakeTerms) + +// fetchBody is a POST /api/components/fetch body that agreed to fakeTerms. +func fetchBody(extra string) string { + return `{"accept_license":true,"license_sha256":"` + fakeTermsSHA + `"` + extra + `}` +} + +func (f *fakeDiag) Components(context.Context) (*DiagComponentsResult, error) { + return &DiagComponentsResult{Root: fakeRoot.Root, Components: []DiagComponent{{ID: "adb", Name: "adb", Installed: false}}}, nil +} +func (f *fakeDiag) License(context.Context, components.SourceSettings) (*DiagLicenseResult, error) { + return &DiagLicenseResult{Text: fakeTerms, Source: "mirror", SHA256: fakeTermsSHA}, nil +} +func (f *fakeDiag) Fetch(ctx context.Context, accept bool, _ components.SourceSettings, _ string, progress func(DiagProgress)) (*DiagFetchResult, error) { + if !accept { + return nil, errs.New(errs.Usage, "必须同意") + } + f.mu.Lock() + f.fetchCalls++ + f.mu.Unlock() + if f.fetchStart != nil { + select { + case <-f.fetchStart: + default: + close(f.fetchStart) + } + } + progress(DiagProgress{Stage: "download", Done: 1, Total: 10}) + select { + case <-f.fetchReturn: + case <-ctx.Done(): + if f.canceled != nil { + close(f.canceled) + } + return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + progress(DiagProgress{Stage: "done", Done: 10, Total: 10}) + return &DiagFetchResult{Root: fakeRoot.Root}, nil +} +func (f *fakeDiag) Collect(context.Context, DiagCollectOptions, func(DiagProgress)) (*DiagCollectResult, error) { + zip := f.zipPath + if zip == "" { + zip = `C:\tmp\diag.zip` + } + return &DiagCollectResult{ZipPath: zip, Sections: []DiagSectionResult{{Name: "目录"}, {Name: "日志", Error: "缺少"}}, Errors: []string{"日志:缺少"}}, nil +} +func (f *fakeDiag) ReportConfig(context.Context) (string, error) { return "config", nil } + +func diagHarness(t *testing.T, d *fakeDiag, mod func(*Options)) *harness { + t.Helper() + return newHarness(t, func(o *Options) { + o.newDiagBackend = func(*logx.Logger) diagBackend { return d } + if mod != nil { + mod(o) + } + }) +} + +func TestDiagnosticsEndpointsAndGuards(t *testing.T) { + d := &fakeDiag{fetchStart: make(chan struct{}), fetchReturn: make(chan struct{}), canceled: make(chan struct{})} + h := diagHarness(t, d, nil) + expectError(t, h.do("GET", "/api/components", nil, noCookie), http.StatusUnauthorized, "unauthorized") + expectError(t, h.do("POST", "/api/components/fetch", `{"accept_license":false,"license_sha256":"`+fakeTermsSHA+`"}`), http.StatusBadRequest, errs.Usage) + // agreeing without the hash of the text that was read, or with something that is not one, is not consent either + expectError(t, h.do("POST", "/api/components/fetch", `{"accept_license":true}`), http.StatusBadRequest, errs.Usage) + expectError(t, h.do("POST", "/api/components/fetch", `{"accept_license":true,"license_sha256":"abc"}`), http.StatusBadRequest, errs.Usage) + d.mu.Lock() + if d.fetchCalls != 0 { + t.Fatalf("fetch called for rejected license") + } + d.mu.Unlock() + var j jobView + expect(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusAccepted, &j) + if j.Kind != kindComponents || j.Index != rootJobIndex { + t.Fatalf("root job %+v", j) + } + <-d.fetchStart + expectError(t, h.do("POST", "/api/instances/0/start", `{}`), http.StatusConflict, errs.Busy) + expectError(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusConflict, errs.Busy) + expect(t, h.do("POST", "/api/jobs/"+j.ID+"/cancel", `{}`), http.StatusOK, nil) + select { + case <-d.canceled: + case <-time.After(2 * time.Second): + t.Fatal("cancel did not reach diagnostic backend") + } + h.waitJob(j.ID, stateIs(stateCanceled)) +} + +func TestDiagnosticsCollectSectionsAndPlaceholder(t *testing.T) { + d := &fakeDiag{} + h := diagHarness(t, d, nil) + var j jobView + expect(t, h.do("POST", "/api/diag/collect", `{"keep_stage":true}`), http.StatusAccepted, &j) + v := h.waitJob(j.ID, stateIs(stateDone)) + res, ok := v.Result.(map[string]any) + if !ok || len(res["sections"].([]any)) != 2 { + t.Fatalf("collect result %+v", v.Result) + } + h2 := diagHarness(t, &fakeDiag{}, nil) + var cfg struct { + Text string `json:"text"` + } + expect(t, h2.do("GET", "/api/diag/report-config", nil), http.StatusOK, &cfg) + if cfg.Text != "config" { + t.Fatalf("report config %q", cfg.Text) + } +} + +func TestDiagnosticsProgressThrottled(t *testing.T) { + d := &fakeDiag{fetchStart: make(chan struct{}), fetchReturn: make(chan struct{})} + h := diagHarness(t, d, nil) + stream := h.events() + var j jobView + expect(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusAccepted, &j) + <-d.fetchStart + first := waitFor[jobView](stream, "job", func(v jobView) bool { return v.ID == j.ID && v.Progress != nil }) + // A second callback immediately after the first is dropped by the 500 ms limiter. + if first.Progress == nil { + t.Fatal("missing first progress") + } + close(d.fetchReturn) + h.waitJob(j.ID, stateIs(stateDone)) +} + +func TestComponentsRejectedWhileStarting(t *testing.T) { + d := &fakeDiag{fetchStart: make(chan struct{}), fetchReturn: make(chan struct{})} + h := diagHarness(t, d, nil) + gate := make(chan struct{}) + h.f.mu.Lock() + h.f.startGate = gate + h.f.mu.Unlock() + var sj jobView + expect(t, h.do("POST", "/api/instances/3/start", `{}`), http.StatusAccepted, &sj) + deadline := time.Now().Add(time.Second) + for { + h.f.mu.Lock() + started := len(h.f.starts) > 0 + h.f.mu.Unlock() + if started || time.Now().After(deadline) { + break + } + time.Sleep(5 * time.Millisecond) + } + expectError(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusConflict, errs.Busy) + close(gate) + h.waitJob(sj.ID, stateIs(stateDone)) +} diff --git a/internal/ui/diag_source_test.go b/internal/ui/diag_source_test.go new file mode 100644 index 0000000..13042ab --- /dev/null +++ b/internal/ui/diag_source_test.go @@ -0,0 +1,168 @@ +//go:build windows + +package ui + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "modavd/internal/components" + "modavd/internal/logx" +) + +// sourceRecordingDiag records the source and license hash each call got, and logs through the logger its factory +// was given (one per job: the job's logger). +type sourceRecordingDiag struct { + fakeDiag + log *logx.Logger + mu sync.Mutex + fetched []components.SourceSettings + hashes []string + licensed []components.SourceSettings +} + +type sourceRecorder struct { + mu sync.Mutex + made []*sourceRecordingDiag +} + +func (r *sourceRecorder) backend(l *logx.Logger) diagBackend { + r.mu.Lock() + defer r.mu.Unlock() + d := &sourceRecordingDiag{log: l} + r.made = append(r.made, d) + return d +} + +func (r *sourceRecorder) all() (fetched []components.SourceSettings, hashes []string, licensed []components.SourceSettings) { + r.mu.Lock() + defer r.mu.Unlock() + for _, d := range r.made { + d.mu.Lock() + fetched, hashes, licensed = append(fetched, d.fetched...), append(hashes, d.hashes...), append(licensed, d.licensed...) + d.mu.Unlock() + } + return +} + +func (d *sourceRecordingDiag) License(_ context.Context, s components.SourceSettings) (*DiagLicenseResult, error) { + d.mu.Lock() + d.licensed = append(d.licensed, s) + d.mu.Unlock() + return &DiagLicenseResult{Text: fakeTerms, Source: "mirror", SHA256: fakeTermsSHA}, nil +} + +func (d *sourceRecordingDiag) Fetch(_ context.Context, _ bool, s components.SourceSettings, hash string, _ func(DiagProgress)) (*DiagFetchResult, error) { + d.mu.Lock() + d.fetched = append(d.fetched, s) + d.hashes = append(d.hashes, hash) + d.mu.Unlock() + d.log.Debugf("许可协议全文") + d.log.Infof("下载源:测试") + return &DiagFetchResult{Root: fakeRoot.Root}, nil +} + +// The source picked in the dialog reaches the fetch (and the hash of the license read from it); without one in the +// request the saved source is used. +func TestComponentsFetchUsesRequestOrSavedSource(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + rec := &sourceRecorder{} + h := newHarness(t, func(o *Options) { o.newDiagBackend = rec.backend }) + var j jobView + expect(t, h.do("POST", "/api/components/fetch", fetchBody(`,"source":"tencent"`)), http.StatusAccepted, &j) + h.waitJob(j.ID, stateIs(stateDone)) + expect(t, h.do("PUT", "/api/components/source", `{"source":"custom","mirror_urls":["https://m.example/sdk"]}`), http.StatusOK, nil) + expect(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusAccepted, &j) + h.waitJob(j.ID, stateIs(stateDone)) + fetched, hashes, _ := rec.all() + if len(fetched) != 2 || fetched[0].Mirror != "tencent" || len(fetched[0].MirrorURLs) != 0 || + fetched[1].Mirror != "" || len(fetched[1].MirrorURLs) != 1 || fetched[1].MirrorURLs[0] != "https://m.example/sdk/" { + t.Fatalf("sources reaching the backend: %+v", fetched) + } + if len(hashes) != 2 || hashes[0] != fakeTermsSHA || hashes[1] != fakeTermsSHA { + t.Fatalf("license hashes reaching the backend: %q", hashes) + } +} + +// The dialog's license is read from the source it shows (a changed choice reads it again); the saved one without a +// source in the query; a bad source is refused before going online. +func TestComponentLicenseFollowsSource(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + rec := &sourceRecorder{} + h := newHarness(t, func(o *Options) { o.newDiagBackend = rec.backend }) + var lic DiagLicenseResult + expect(t, h.do("GET", "/api/components/license?source=custom&mirror_urls=https://a.example/x&mirror_urls=https://b.example/y/", nil), http.StatusOK, &lic) + if lic.SHA256 != fakeTermsSHA || lic.Text != fakeTerms { + t.Fatalf("license %+v", lic) + } + expect(t, h.do("GET", "/api/components/license?source=google", nil), http.StatusOK, nil) + expect(t, h.do("PUT", "/api/components/source", `{"source":"tencent"}`), http.StatusOK, nil) + expect(t, h.do("GET", "/api/components/license", nil), http.StatusOK, nil) + expectError(t, h.do("GET", "/api/components/license?source=custom", nil), http.StatusBadRequest, "usage") + expectError(t, h.do("GET", "/api/components/license?source=aliyun", nil), http.StatusBadRequest, "invalid_argument") + _, _, licensed := rec.all() + if len(licensed) != 3 || len(licensed[0].MirrorURLs) != 2 || licensed[0].MirrorURLs[1] != "https://b.example/y/" || + licensed[1].Mirror != "google" || licensed[2].Mirror != "tencent" { + t.Fatalf("license sources: %+v", licensed) + } +} + +// A component fetch logs as its job: messages carry the job id in the event stream, the license text (Debug) stays +// out of it. +func TestComponentsFetchLogsAsItsJob(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + rec := &sourceRecorder{} + h := newHarness(t, func(o *Options) { o.newDiagBackend = rec.backend }) + stream := h.events() + var j jobView + expect(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusAccepted, &j) + // the Debug line is logged first: had it reached the stream, it would come before the Info one + waitFor[logEvent](stream, "log", func(l logEvent) bool { + if l.Msg == "许可协议全文" { + t.Fatalf("debug line in the event stream: %+v", l) + } + return l.Msg == "下载源:测试" && l.JobID == j.ID + }) + h.waitJob(j.ID, stateIs(stateDone)) +} + +// 在资源管理器里显示 opens the folder of a package a collect-info job made, named by the job; never a path from the +// page. +func TestOpenCollectInfoFolder(t *testing.T) { + dir := t.TempDir() + zip := filepath.Join(dir, "问题信息.zip") + if err := os.WriteFile(zip, []byte("PK"), 0o644); err != nil { + t.Fatal(err) + } + d := &fakeDiag{zipPath: zip, fetchReturn: make(chan struct{})} + close(d.fetchReturn) + h := diagHarness(t, d, nil) + var cj, fj jobView + expect(t, h.do("POST", "/api/diag/collect", `{}`), http.StatusAccepted, &cj) + h.waitJob(cj.ID, stateIs(stateDone)) + expect(t, h.do("POST", "/api/open", `{"what":"collect-info","job":"`+cj.ID+`"}`), http.StatusOK, nil) + expect(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusAccepted, &fj) + h.waitJob(fj.ID, stateIs(stateDone)) + expectError(t, h.do("POST", "/api/open", `{"what":"collect-info","job":"`+fj.ID+`"}`), http.StatusNotFound, "not_found") + expectError(t, h.do("POST", "/api/open", `{"what":"collect-info","job":"j999"}`), http.StatusNotFound, "not_found") + expectError(t, h.do("POST", "/api/open", `{"what":"collect-info"}`), http.StatusBadRequest, "usage") + // a path from the page is not taken any more (unknown field), and the what values say collect-info + expectError(t, h.do("POST", "/api/open", `{"what":"collect-info","job":"`+cj.ID+`","path":"C:\\Windows"}`), http.StatusBadRequest, "usage") + if e := expectError(t, h.do("POST", "/api/open", `{"what":"path"}`), http.StatusBadRequest, "invalid_argument"); !strings.Contains(e.Message, "collect-info") { + t.Errorf("what error %q", e.Message) + } + if err := os.Remove(zip); err != nil { + t.Fatal(err) + } + expectError(t, h.do("POST", "/api/open", `{"what":"collect-info","job":"`+cj.ID+`"}`), http.StatusNotFound, "not_found") + h.mu.Lock() + defer h.mu.Unlock() + if len(h.opened) != 1 || h.opened[0] != dir { + t.Fatalf("opened %q, want only %q", h.opened, dir) + } +} diff --git a/internal/ui/diagnostics.go b/internal/ui/diagnostics.go new file mode 100644 index 0000000..cf1d7c1 --- /dev/null +++ b/internal/ui/diagnostics.go @@ -0,0 +1,91 @@ +package ui + +import ( + "context" + + "modavd/internal/components" +) + +type DiagComponent struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Build string `json:"build,omitempty"` + Installed bool `json:"installed"` + Optional bool `json:"optional"` + LocalSDK bool `json:"local_sdk"` +} + +type DiagComponentsResult struct { + Root string `json:"root"` + Components []DiagComponent `json:"components"` +} + +type DiagLicenseResult struct { + Text string `json:"license"` + Source string `json:"license_source"` + SHA256 string `json:"license_sha256"` +} + +type DiagProgress struct { + Stage string `json:"stage"` + Name string `json:"name,omitempty"` + Message string `json:"message"` + Done int64 `json:"done"` + Total int64 `json:"total"` + Speed float64 `json:"speed"` + Remaining float64 `json:"remaining"` +} + +type DiagFetchComponent struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Installed bool `json:"installed"` + Downloaded bool `json:"downloaded"` + Skipped bool `json:"skipped"` + SetAside string `json:"set_aside"` +} + +type DiagFetchResult struct { + Root string `json:"root"` + License string `json:"license"` + LicenseSource string `json:"license_source"` + Components []DiagFetchComponent `json:"components"` + Downloads []string `json:"downloads"` +} + +type DiagCollectOptions struct { + OutDir string `json:"out_dir"` + StageDir string `json:"stage_dir"` + KeepStage bool `json:"keep_stage"` +} + +type DiagSectionResult struct { + Name string `json:"name"` + Error string `json:"error,omitempty"` +} + +type DiagCollectResult struct { + ZipPath string `json:"zip_path"` + ZipBytes int64 `json:"zip_bytes"` + ZipSize string `json:"zip_size"` + Entries []string `json:"entries"` + Stage string `json:"stage"` + StageKept bool `json:"stage_kept"` + StageError string `json:"stage_error"` + Problems []string `json:"problems"` + MasFound bool `json:"mas_found"` + Summary string `json:"summary"` + PrivacyNote string `json:"privacy_note"` + Errors []string `json:"errors,omitempty"` + Sections []DiagSectionResult `json:"sections,omitempty"` +} + +type diagBackend interface { + Components(context.Context) (*DiagComponentsResult, error) + License(context.Context, components.SourceSettings) (*DiagLicenseResult, error) + Fetch(context.Context, bool, components.SourceSettings, string, func(DiagProgress)) (*DiagFetchResult, error) + Collect(context.Context, DiagCollectOptions, func(DiagProgress)) (*DiagCollectResult, error) + ReportConfig(context.Context) (string, error) +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index a02bb46..aa8e3fa 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -2,15 +2,19 @@ package ui import ( "context" + "encoding/hex" "fmt" "net/http" "os" "path/filepath" + "strings" "time" "modavd/internal/buildinfo" + "modavd/internal/components" "modavd/internal/emu" "modavd/internal/errs" + "modavd/internal/logx" "modavd/internal/root" ) @@ -237,6 +241,7 @@ func (s *server) handleOpen(w http.ResponseWriter, r *http.Request) { var body struct { What string `json:"what"` Index *int `json:"index"` + Job string `json:"job"` } if !decode(w, r, &body) { return @@ -266,8 +271,16 @@ func (s *server) handleOpen(w http.ResponseWriter, r *http.Request) { writeError(w, errs.Newf(errs.NotFound, "实例 %d 的文件夹不存在: %s", *body.Index, dir)) return } + case "collect-info": + // only the folder of a problem package this manager made: the page names the job, never a path + zip, err := s.collectZip(body.Job) + if err != nil { + writeError(w, err) + return + } + dir = filepath.Dir(zip) default: - writeError(w, errs.Newf(errs.InvalidArgument, "what 只能是 root、logs 或 instance(给的是 %q)", body.What)) + writeError(w, errs.Newf(errs.InvalidArgument, "what 只能是 root、logs、instance 或 collect-info(给的是 %q)", body.What)) return } if err := s.openPath(dir); err != nil { @@ -277,6 +290,195 @@ func (s *server) handleOpen(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, struct{}{}) } +// collectZip is the problem package of a finished 收集问题信息 job (errs.NotFound when the job is not one, or the +// package is gone). +func (s *server) collectZip(id string) (string, error) { + if id == "" { + return "", errs.New(errs.Usage, "打开问题包所在文件夹要给 job(收集问题信息的任务 id)") + } + j, err := s.jobByID(id) + if err != nil { + return "", err + } + v := s.jobView(j) + res, ok := v.Result.(*DiagCollectResult) + if v.Kind != kindCollect || v.State != stateDone || !ok || res == nil || res.ZipPath == "" { + return "", errs.Newf(errs.NotFound, "任务 %s 不是做完了的收集问题信息", id) + } + if st, err := os.Stat(res.ZipPath); err != nil || st.IsDir() { + return "", errs.Newf(errs.NotFound, "问题包已经不在了: %s", res.ZipPath) + } + return res.ZipPath, nil +} + +func (s *server) handleComponents(w http.ResponseWriter, r *http.Request) { + res, err := s.diag.Components(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (s *server) handleComponentLicense(w http.ResponseWriter, r *http.Request) { + source := components.LoadSourceSettings() + if q := r.URL.Query(); q.Get("source") != "" || q.Get("mirror") != "" || len(q["mirror_urls"]) > 0 { + urls := append([]string(nil), q["mirror_urls"]...) + var err error + source, err = componentSourceFromFields(q.Get("source"), q.Get("mirror"), urls) + if err != nil { + writeError(w, err) + return + } + } + res, err := s.diag.License(r.Context(), source) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +type componentSourceView struct { + Source string `json:"source"` + Mirror string `json:"mirror"` + MirrorURLs []string `json:"mirror_urls"` + Configured bool `json:"configured"` +} + +func componentSourceViewOf(s components.SourceSettings) componentSourceView { + source := "auto" + if len(s.MirrorURLs) > 0 { + source = "custom" + } else if s.Mirror != "" { + source = s.Mirror + } + urls := append([]string{}, s.MirrorURLs...) + return componentSourceView{Source: source, Mirror: s.Mirror, MirrorURLs: urls, Configured: s.Set} +} + +func componentSourceFromFields(source, mirror string, urls []string) (components.SourceSettings, error) { + source = strings.ToLower(strings.TrimSpace(source)) + mirror = strings.TrimSpace(mirror) + switch source { + case "auto": + mirror, urls = "", nil + case "googledownloads_cn", "tencent", "google": + mirror, urls = source, nil + case "custom": + if len(urls) == 0 { + return components.SourceSettings{}, errs.New(errs.Usage, "自定义组件下载源至少要有一个地址") + } + mirror = "" + case "": + if mirror == "" && len(urls) == 0 { + return components.SourceSettings{}, errs.New(errs.Usage, "组件下载源必须给 source 或 mirror_urls") + } + default: + return components.SourceSettings{}, errs.Newf(errs.InvalidArgument, "组件下载源只能是 auto、googledownloads_cn、tencent、google 或 custom,收到「%s」", source) + } + return components.SourceSettings{Mirror: mirror, MirrorURLs: urls, Set: true}, nil +} + +func (s *server) handleComponentSource(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, componentSourceViewOf(components.LoadSourceSettings())) + return + } + var body struct { + Source string `json:"source"` + Mirror string `json:"mirror"` + MirrorURLs []string `json:"mirror_urls"` + } + if !decode(w, r, &body) { + return + } + source, err := componentSourceFromFields(body.Source, body.Mirror, body.MirrorURLs) + if err != nil { + writeError(w, err) + return + } + saved, err := components.SaveSourceSettings(source.Mirror, source.MirrorURLs) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, componentSourceViewOf(saved)) +} + +func (s *server) handleComponentsFetch(w http.ResponseWriter, r *http.Request) { + var body struct { + AcceptLicense *bool `json:"accept_license"` + Source string `json:"source"` + Mirror string `json:"mirror"` + MirrorURLs []string `json:"mirror_urls"` + LicenseSHA256 string `json:"license_sha256"` + } + if !decode(w, r, &body) { + return + } + if body.AcceptLicense == nil || !*body.AcceptLicense { + writeError(w, errs.New(errs.Usage, "补全组件必须明确同意许可协议").WithAdvice("先阅读许可协议,再发送 accept_license: true")) + return + } + // the consent is to the text the dialog showed: GET /api/components/license gave its SHA-256, and the fetch stops + // when the mirror now serves another one + if !isSHA256Hex(body.LicenseSHA256) { + writeError(w, errs.New(errs.Usage, "补全组件要带上看过的许可协议的 license_sha256"). + WithAdvice("先用 GET /api/components/license 取许可协议(和这次一样的下载源),把它的 license_sha256 原样带上")) + return + } + source := components.LoadSourceSettings() + if body.Source != "" || body.Mirror != "" || len(body.MirrorURLs) > 0 { + var err error + source, err = componentSourceFromFields(body.Source, body.Mirror, body.MirrorURLs) + if err != nil { + writeError(w, err) + return + } + } + v, err := s.startRootJob(kindComponents, func(ctx context.Context, j *job, _ backend) (any, error) { + return s.jobDiag(j).Fetch(ctx, true, source, body.LicenseSHA256, func(p DiagProgress) { s.setDiagProgress(j, p) }) + }) + s.respondJob(w)(v, err) +} + +// jobDiag is the diagnostic backend of one job: its messages are the job's (file log tagged with the job id, the +// job's lines in the event stream, warnings), like the instance backend startJob gives the other jobs. +func (s *server) jobDiag(j *job) diagBackend { + return s.newDiag(logx.New(s.jobLogSink(j))) +} + +func isSHA256Hex(v string) bool { + if len(v) != 64 { + return false + } + _, err := hex.DecodeString(v) + return err == nil +} + +func (s *server) handleDiagCollect(w http.ResponseWriter, r *http.Request) { + var body DiagCollectOptions + if !decode(w, r, &body) { + return + } + v, err := s.startRootJob(kindCollect, func(ctx context.Context, j *job, _ backend) (any, error) { + return s.jobDiag(j).Collect(ctx, body, func(p DiagProgress) { s.setDiagProgress(j, p) }) + }) + s.respondJob(w)(v, err) +} + +func (s *server) handleReportConfig(w http.ResponseWriter, r *http.Request) { + text, err := s.diag.ReportConfig(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Text string `json:"text"` + }{text}) +} + func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) { refresh := r.URL.Query().Get("refresh") == "1" asked := time.Now() @@ -287,8 +489,12 @@ func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) { } defer c.unlock() // a check that finished while this request waited is as fresh as one started for it - if c.res != nil && (!refresh || c.at.After(asked)) { - writeJSON(w, http.StatusOK, c.res) + c.mu.Lock() + cached, cachedAt := c.res, c.at + generation := c.generation + c.mu.Unlock() + if cached != nil && (!refresh || cachedAt.After(asked)) { + writeJSON(w, http.StatusOK, cached) return } res, err := s.mgr.Check(r.Context(), emu.CheckOptions{}) @@ -296,7 +502,11 @@ func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - c.res, c.at = res, time.Now() + c.mu.Lock() + if c.generation == generation { + c.res, c.at = res, time.Now() + } + c.mu.Unlock() writeJSON(w, http.StatusOK, res) } diff --git a/internal/ui/http.go b/internal/ui/http.go index 00ab2f5..586011b 100644 --- a/internal/ui/http.go +++ b/internal/ui/http.go @@ -46,6 +46,13 @@ func (s *server) routes() { m.HandleFunc("GET /api/proxy/suggest", s.handleProxySuggest) m.HandleFunc("POST /api/proxy/probe", s.handleProxyProbe) m.HandleFunc("POST /api/open", s.handleOpen) + m.HandleFunc("GET /api/components", s.handleComponents) + m.HandleFunc("GET /api/components/license", s.handleComponentLicense) + m.HandleFunc("GET /api/components/source", s.handleComponentSource) + m.HandleFunc("PUT /api/components/source", s.handleComponentSource) + m.HandleFunc("POST /api/components/fetch", s.handleComponentsFetch) + m.HandleFunc("POST /api/diag/collect", s.handleDiagCollect) + m.HandleFunc("GET /api/diag/report-config", s.handleReportConfig) m.HandleFunc("GET /api/check", s.handleCheck) m.HandleFunc("GET /api/jobs", s.handleJobs) m.HandleFunc("GET /api/jobs/{id}", s.handleJob) diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index f1dedb2..3413ca8 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -6,6 +6,7 @@ import ( "runtime/debug" "slices" "strconv" + "strings" "sync" "time" @@ -15,10 +16,13 @@ import ( ) const ( - kindStart = "start" - kindStop = "stop" - kindInstall = "install" - kindCreate = "create" + kindStart = "start" + kindStop = "stop" + kindInstall = "install" + kindCreate = "create" + kindComponents = "components" + kindCollect = "collect-info" + rootJobIndex = -1 kindDiskInfo = "disk-info" kindDiskTrim = "disk-trim" @@ -44,6 +48,10 @@ func kindLabel(kind string) string { return "安装 APK" case kindCreate: return "新建" + case kindComponents: + return "补全组件" + case kindCollect: + return "收集问题信息" case kindDiskInfo: return "查看磁盘占用" case kindDiskTrim: @@ -55,10 +63,13 @@ func kindLabel(kind string) string { } type jobProgress struct { - Step string `json:"step"` - Message string `json:"message"` - Done int `json:"done"` - Total int `json:"total"` + Step string `json:"step"` + Name string `json:"name,omitempty"` + Message string `json:"message"` + Done int `json:"done"` + Total int `json:"total"` + Speed float64 `json:"speed,omitempty"` + Remaining float64 `json:"remaining,omitempty"` } // jobView is the Job of API.md. @@ -77,9 +88,12 @@ type jobView struct { // job: view is guarded by jobTable.mu; cancel and done never change. type job struct { - view jobView - cancel context.CancelFunc - done chan struct{} // closed when the job function returned + view jobView + cancel context.CancelFunc + done chan struct{} // closed when the job function returned + progressAt time.Time + progressStage string + progressName string } type jobTable struct { @@ -182,6 +196,16 @@ type jobFunc func(ctx context.Context, j *job, b backend) (any, error) // job (file log with the job id, log events, warnings). errs.Busy when the instance has a conflicting job. func (s *server) startJob(kind string, i int, fn jobFunc) (jobView, error) { s.jobs.mu.Lock() + for _, o := range s.jobs.list { + if o.view.State != stateRunning { + continue + } + if (kind == kindComponents && o.view.Index != rootJobIndex && (o.view.Kind == kindStart || o.view.Kind == kindCreate || o.view.Kind == "restart")) || + (i != rootJobIndex && o.view.Kind == kindComponents && (kind == kindStart || kind == kindCreate || kind == "restart")) { + s.jobs.mu.Unlock() + return jobView{}, errs.Newf(errs.Busy, "任务 %s 正在运行(任务 %s),等它做完再试", kindLabel(o.view.Kind), o.view.ID) + } + } if o := s.jobs.busyWith(kind, i); o != nil { s.jobs.mu.Unlock() return jobView{}, busyError(o) @@ -206,12 +230,25 @@ func (s *server) startJob(kind string, i int, fn jobFunc) (jobView, error) { s.jobs.mu.Unlock() b := s.newBackend(logx.New(s.jobLogSink(j))) - s.log.Infof("[%s] 开始%s实例 %d", view.ID, kindLabel(kind), i) + s.log.Infof("[%s] 开始%s%s", view.ID, kindLabel(kind), strings.TrimSpace(jobTarget(i))) s.publishJob(view, true) go s.runJob(ctx, j, b, fn) return view, nil } +// jobTarget is what a job works on, for the log lines: "实例 3 " (with the space the lines had), nothing for a root +// job (components, collect-info). +func jobTarget(i int) string { + if i == rootJobIndex { + return "" + } + return "实例 " + strconv.Itoa(i) + " " +} + +func (s *server) startRootJob(kind string, fn jobFunc) (jobView, error) { + return s.startJob(kind, rootJobIndex, fn) +} + func (s *server) runJob(ctx context.Context, j *job, b backend, fn jobFunc) { defer close(j.done) defer j.cancel() @@ -249,14 +286,20 @@ func (s *server) finishJob(ctx context.Context, j *job, res any, err error) { v.Error = newErrorBody(err) view := j.snapshot() s.jobs.mu.Unlock() + if view.Kind == kindComponents { + s.check.mu.Lock() + s.check.res, s.check.at = nil, time.Time{} + s.check.generation++ + s.check.mu.Unlock() + } switch view.State { case stateDone: - s.log.Infof("[%s] %s实例 %d 完成", view.ID, kindLabel(view.Kind), view.Index) + s.log.Infof("[%s] %s%s完成", view.ID, kindLabel(view.Kind), jobTarget(view.Index)) case stateCanceled: - s.log.Infof("[%s] %s实例 %d 已取消", view.ID, kindLabel(view.Kind), view.Index) + s.log.Infof("[%s] %s%s已取消", view.ID, kindLabel(view.Kind), jobTarget(view.Index)) default: - s.log.Warnf("[%s] %s实例 %d 失败: %s", view.ID, kindLabel(view.Kind), view.Index, errs.As(err).Error()) + s.log.Warnf("[%s] %s%s失败: %s", view.ID, kindLabel(view.Kind), jobTarget(view.Index), errs.As(err).Error()) } s.publishJob(view, true) } @@ -281,6 +324,24 @@ func (s *server) setProgress(j *job, p jobProgress) { s.publishJob(view, true) } +func (s *server) setDiagProgress(j *job, p DiagProgress) { + s.jobs.mu.Lock() + if j.view.State != stateRunning { + s.jobs.mu.Unlock() + return + } + now := time.Now() + if !j.progressAt.IsZero() && now.Sub(j.progressAt) < 500*time.Millisecond && j.progressStage == p.Stage && j.progressName == p.Name { + s.jobs.mu.Unlock() + return + } + j.progressAt, j.progressStage, j.progressName = now, p.Stage, p.Name + j.view.Progress = &jobProgress{Step: p.Stage, Name: p.Name, Message: p.Message, Done: int(p.Done), Total: int(p.Total), Speed: p.Speed, Remaining: p.Remaining} + view := j.snapshot() + s.jobs.mu.Unlock() + s.publishJob(view, false) +} + // progressFn forwards emu's start progress into the job. func (s *server) progressFn(j *job) func(emu.Progress) { return func(p emu.Progress) { diff --git a/internal/ui/main_test.go b/internal/ui/main_test.go new file mode 100644 index 0000000..fdceef2 --- /dev/null +++ b/internal/ui/main_test.go @@ -0,0 +1,11 @@ +package ui + +import ( + "os" + "testing" + + "modavd/internal/testutil" +) + +// No test here touches the real per-user directories (component download source, update settings, running emulators). +func TestMain(m *testing.M) { os.Exit(testutil.RunWithTempUserDirs(m)) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2678e4c..196719f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -52,13 +52,14 @@ type Options struct { IdleExit time.Duration // Test hooks. - newBackend func(log *logx.Logger) backend - token string - web fs.FS - openPath func(path string) error - launch func(url string) (*Window, error) - removeOld func(log *logx.Logger) // removeOldEdgeProfile on AppDataDir - proxyEvery time.Duration + newBackend func(log *logx.Logger) backend + newDiagBackend func(log *logx.Logger) diagBackend + token string + web fs.FS + openPath func(path string) error + launch func(url string) (*Window, error) + removeOld func(log *logx.Logger) // removeOldEdgeProfile on AppDataDir + proxyEvery time.Duration } const ( @@ -85,6 +86,8 @@ type server struct { mgr backend // requests outside jobs; its messages also go to the event stream poll backend // the instance poller: file log only, it runs every few seconds newBackend func(log *logx.Logger) backend + diag diagBackend // requests outside jobs (component status, license, config text) + newDiag func(log *logx.Logger) diagBackend // one per diagnostic job, with the job's logger token string dev bool files map[string]*staticFile @@ -125,6 +128,11 @@ func newServer(opt Options) (*server, error) { r := opt.Root newBackend = func(l *logx.Logger) backend { return managerBackend{emu.New(r, l)} } } + newDiagBackend := opt.newDiagBackend + if newDiagBackend == nil { + r := opt.Root + newDiagBackend = func(l *logx.Logger) diagBackend { return newRealDiagBackend(r, l) } + } token := opt.token if token == "" { b := make([]byte, 32) @@ -154,6 +162,8 @@ func newServer(opt Options) (*server, error) { log: lg, poll: newBackend(lg), newBackend: newBackend, + diag: newDiagBackend(lg), + newDiag: newDiagBackend, token: token, dev: opt.Dev, files: files, @@ -353,10 +363,12 @@ func (w logWriter) Write(p []byte) (int, error) { // on every visit of its check view. sem serializes runs; a request waits for a run in progress instead of starting // another one. type checkCache struct { - once sync.Once - sem chan struct{} - res *emu.CheckResult - at time.Time + once sync.Once + sem chan struct{} + mu sync.Mutex + generation uint64 + res *emu.CheckResult + at time.Time } func (c *checkCache) lock(ctx context.Context) error { diff --git a/internal/ui/web/app.css b/internal/ui/web/app.css index bd151cc..c5d9661 100644 --- a/internal/ui/web/app.css +++ b/internal/ui/web/app.css @@ -882,6 +882,7 @@ dialog > .toasts .toast.out { animation-name: toast-out-left; } .upd-msg { margin: 0; color: var(--text); } .upd-cdk { display: flex; gap: 8px; align-items: center; } .upd-cdk .input { flex: 1; min-width: 0; } +.upd-urls { margin-top: 8px; } .upd-dot { min-width: 8px; height: 8px; padding: 0; } /* 按应用走代理 */ .dlg-proxy { --w: 520px; } @@ -909,6 +910,31 @@ dialog > .toasts .toast.out { animation-name: toast-out-left; } .p-count.tone-err { color: var(--err); } .p-gms { margin: 16px 0 0; } .p-state { margin-bottom: 0; } +.diag-launch { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); } +.diag-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; } +.diag-head strong { font-size: 14px; font-weight: 600; } +.diag-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.diag-components { margin-top: 8px; border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; } +.diag-components:empty::before { content: "正在读取组件状态…"; display: block; padding: 12px; color: var(--text-3); font-size: 13px; } +.diag-components .comp-row { grid-template-columns: minmax(0, 1fr) auto auto; } +.diag-running { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; color: var(--text-2); font-size: 13px; } +.diag-running .btn { margin-left: auto; } +.diag-path { margin: 10px 0 0; color: var(--text-2); font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; } +.diag-privacy { white-space: pre-line; } +.diag-sections { margin: 10px 0 0; padding-left: 20px; font-size: 12px; } +.diag-license { max-height: min(52vh, 460px); white-space: pre-wrap; } +.diag-config { max-height: min(58vh, 520px); white-space: pre-wrap; } +/* 电脑检查 → 组件: 补全组件, the running fetch, the last failure */ +.ci-fetch { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; margin-top: 8px; } +.ci-fetch .diag-running { margin: 0; overflow-wrap: anywhere; } +.ci-fetch .form-error { margin: 0; } +.diag-state { margin: 10px 0 0; color: var(--tf, var(--text-2)); font-size: 13px; overflow-wrap: anywhere; } +/* the license dialog: source first, then where the text came from and the text */ +.diag-license-body { display: flex; flex-direction: column; gap: 12px; padding-top: 8px; } +.diag-license-body .fields { row-gap: 12px; } +.diag-license-body .form-error { margin-top: 0; } +.diag-license-body .form-error > div { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.diag-license-body .form-error > .btn { flex: none; } .check-sum { display: flex; align-items: center; gap: 12px; min-height: 68px; padding: 12px 16px; margin-bottom: 8px; border-radius: var(--r-card); background: var(--tb); } .check-sum > .sk-row { flex: 1; margin: 0; } .check-sum > .ic { width: 22px; height: 22px; color: var(--tf); } diff --git a/internal/ui/web/dev/mock.js b/internal/ui/web/dev/mock.js index 52cd393..e79c90a 100644 --- a/internal/ui/web/dev/mock.js +++ b/internal/ui/web/dev/mock.js @@ -1,14 +1,15 @@ // Mock backend for design work: ?mock=normal|empty|notready|busy|many replaces fetch('/api/…') and EventSource with // an in-memory fake that follows internal/ui/API.md. Extra params: drop=1 (the event stream drops after 1.5 s and -// stays down), offline=1 (it never connects), shotfail=1 (every screenshot answers 500), proxyfail=1 (instance 0's -// guest says its proxy does not answer). window.__mockShots counts screenshot requests per instance. Never used in +// stays down), offline=1 (it never connects), shotfail=1 (every screenshot answers 500), diag=components-error|components-fail +// (component status request or fetch fails). window.__mockShots counts screenshot requests per instance. Never used in // production: the server does not serve /dev/. // Screens are drawn on a canvas: abstract shapes only, no real game content. const ROOT = 'X:\\ModAVD'; const INDEXES = [0, 1, 2, 3, 4, 6, 7, 8, 9]; const MEM = [3072, 4096, 5120, 6144]; -const db = { insts: new Map(), jobs: [], streams: new Set(), seq: 40, check: 'ok', offline: false, drop: false, ready: true }; +const db = { insts: new Map(), jobs: [], streams: new Set(), seq: 40, check: 'ok', offline: false, drop: false, ready: true, diagMode: '', componentsReady: false, + componentSource: { source: 'auto', mirror: '', mirror_urls: [], configured: false } }; const now = () => Date.now(); const iso = (t = now()) => { const d = new Date(t); @@ -223,14 +224,17 @@ function checkResult() { if (db.check === 'warn') { Object.assign(items[4], { ok: false, reason: '没有找到能用 Vulkan 的显卡(只有 Microsoft Basic Render Driver)', advice: '安装或更新显卡驱动(NVIDIA / AMD / Intel 官网驱动自带 Vulkan)。没有可用的显卡时模拟器只能软件渲染,游戏会占满 CPU,性能会很差' }); } + if (db.check === 'notready' && !db.componentsReady) Object.assign(items[1], { ok: false, reason: '这个目录里缺少:平台工具(adb)、系统镜像 Android 14 Google APIs x86_64', advice: '在管理器里点「补全组件」,或运行 tools\\modavd.exe components fetch;它会先显示《Android 软件开发套件许可协议》,输入 y 同意后从官方渠道下载 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下),完成后再检查一次' }); if (db.check === 'notready') { - Object.assign(items[1], { ok: false, reason: '这个目录里缺少:平台工具(adb)、系统镜像 Android 14 Google APIs x86_64', advice: '运行魔改 AVD 根目录里的「补全组件.bat」:它会先显示《Android 软件开发套件许可协议》,输入 y 同意后从官方渠道下载 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下),看到「完成」后再检查一次' }); Object.assign(items[3], { ok: false, reason: '硬件虚拟化(Windows 虚拟机监控程序平台)不可用,魔改 AVD 无法启动(检查结果:WHPX is not installed)', advice: '先在 BIOS/UEFI 里打开 VT-x/SVM,再在「启用或关闭 Windows 功能」里勾选「Windows 虚拟机监控程序平台」,然后重启' }); Object.assign(items[4], { ok: null, reason: '查不了 Vulkan:探测程序 8 秒内没有结束' }); Object.assign(items[2], { reason: '内测包 0.35.0(37.2.10)' }); + // the host IME is optional and stays missing, also after a fetch (it is not a downloaded component) + comps[4].installed = false; comps[4].version = ''; + } + if (db.check === 'notready' && !db.componentsReady) { comps[0].installed = false; comps[0].version = ''; comps[2].installed = false; comps[2].version = ''; comps[2].build = ''; - comps[4].installed = false; comps[4].version = ''; } const ready = !items.some((x) => x.blocking && x.ok === false); return { ...rootInfo(), ready, items, components: comps }; @@ -289,10 +293,68 @@ function run(j, steps) { next(); } +// [stage, name, done MB, total MB, speed MB/s]: a download's message is the real backend's line (internal/components +// progress.go), name and sizes included; the collect steps only have a message +const mb = (x) => (x >= 1024 ? `${(x / 1024).toFixed(2)} GB` : `${x.toFixed(1)} MB`); +function diagProgress(j, stages, done) { + if (j.state !== 'running') return; + const [step, name, d, total, speed] = stages[Math.min(done, stages.length - 1)]; + const pct = total ? ((d * 100) / total).toFixed(1).padStart(5) : ''; + const message = step === 'downloading' ? ` ${name} ${pct}% ${mb(d)} / ${mb(total)} ${mb(speed)}/s 剩余 00:${String(Math.round((total - d) / speed)).padStart(2, '0')}` + : step === 'extracting' ? ` 解压 ${name} ${pct}% ${mb(d)} / ${mb(total)}` : name; + j.progress = { step, name, message, done: d * 1048576, total: total * 1048576, speed: speed * 1048576 }; + emit('job', j); emit('instances', list()); +} + +// the license each source serves (a custom one says its address), with a stand-in for its SHA-256 +function mockLicense(source, urls) { + const names = { auto: 'Google 中国下载(googledownloads.cn)', googledownloads_cn: 'Google 中国下载(googledownloads.cn)', tencent: '腾讯云镜像(mirrors.cloud.tencent.com)', google: 'Google 官方(dl.google.com)' }; + let from = names[source]; + if (source === 'custom') { + if (!urls.length) return { error: ['usage', '自定义组件下载源至少要有一个地址'] }; + if (urls.some((u) => !/^https?:\/\/[^/?#\s@]+/i.test(u) || /[?#\s]/.test(u))) return { error: ['invalid_argument', `自定义下载地址要写 http:// 或 https:// 开头、带主机名的目录地址,收到「${urls[0]}」`] }; + if (urls.every((u) => /unreachable/.test(u))) return { error: ['components_unavailable', '所有镜像都取不到许可协议(mock)', '检查地址,或换一个下载源'] }; + from = `自定义镜像(${urls[0].replace(/\/?$/, '/')})`; + } + if (!from) return { error: ['invalid_argument', `组件下载源只能是 auto、googledownloads_cn、tencent、google 或 custom,收到「${source}」`] }; + const license = `Terms and Conditions (mock, served by ${from})\n\nThis is the Android Software Development Kit License Agreement as ${from} serves it.\n\n1. Introduction\n1.1 The Android Software Development Kit (referred to in the License Agreement as the "SDK") …`; + let x = 0x811c9dc5; + for (const ch of license) x = Math.imul(x ^ ch.codePointAt(0), 16777619) >>> 0; + return { license, license_source: from, license_sha256: x.toString(16).padStart(8, '0').repeat(8) }; +} + +function runDiag(j, kind = j.kind) { + const stages = kind === 'components' + ? [['downloading', '平台工具(adb)', 2.7, 8.0, 1.1], ['downloading', '平台工具(adb)', 6.4, 8.0, 1.3], ['extracting', '平台工具(adb)', 8.0, 8.0, 0], + ['downloading', '系统镜像(Android 14 Google APIs x86_64)', 310, 1480, 24], ['downloading', '系统镜像(Android 14 Google APIs x86_64)', 960, 1480, 26], + ['extracting', '系统镜像(Android 14 Google APIs x86_64)', 1480, 1480, 0]] + : [['collect', '正在收集目录与版本', 1, 12, 0.4], ['collect', '正在收集日志', 4, 12, 0.8], ['collect', '正在收集现场状态', 8, 12, 1.1], ['zip', '正在压缩问题包', 11, 12, 1.4], ['done', '问题包已生成', 12, 12, 0]]; + if (kind === 'components') ['魔改 AVD:补全 Google 组件(adb、Android 14 系统镜像)', '已在管理器里同意许可协议。'].forEach((m) => log('info', m, j.id)); + let n = 0; + const timer = setInterval(() => { + if (j.state !== 'running') { clearInterval(timer); return; } + diagProgress(j, stages, n); + n++; + // components-fail: the first fetch of the page fails half way (so 重试 can be tried), the next ones work + if (kind === 'components' && db.diagMode === 'components-fail' && !db.diagFailed && n === 3) { + db.diagFailed = true; + clearInterval(timer); failJob(j, 'components_unavailable', '所有镜像都没下成:系统镜像(Android 14 Google APIs x86_64)', '检查网络或换一个下载源后重试;已经下好的部分会接着下'); return; + } + if (n >= stages.length) { + clearInterval(timer); + if (kind === 'components') { + db.componentsReady = true; + finishJob(j, { root: ROOT, license: 'Android SDK License', license_source: 'Google 中国下载(mock)', components: checkResult().components, downloads: [] }); + } else finishJob(j, { zip_path: `${ROOT}\\logs\\魔改AVD问题信息-mock.zip`, zip_size: '1.2 MB', problems: [], mas_found: true, entries: ['summary.txt'], + privacy_note: '隐私提示(mock):问题包只保存在本机,不会自动上传。\r\n- 可以公开:summary.txt 和日志。\r\n- 不要公开:crash 文件夹。' }); + } + }, 500); +} + function startSeq(inst, j, window) { const i = inst.index; if (!db.ready) { - setTimeout(() => failJob(j, 'not_ready', '这个目录里缺少:平台工具(adb)、系统镜像 Android 14 Google APIs x86_64', '运行魔改 AVD 根目录里的「补全组件.bat」,看到「完成」后再检查一次'), 600); + setTimeout(() => failJob(j, 'not_ready', '这个目录里缺少:平台工具(adb)、系统镜像 Android 14 Google APIs x86_64', '在管理器里点「补全组件」,或运行 tools\\modavd.exe components fetch;完成后再检查一次'), 600); return; } if (inst._.fail && inst.config.memory_mb > 4096) { @@ -679,7 +741,46 @@ async function handle(url, init) { emit('instances', list()); return json(pub(inst), 201); } - if (p === '/api/open') return json({}); + if (p === '/api/open') { + if (body.what === 'collect-info') { + const cj = db.jobs.find((j) => j.id === body.job); + return cj && cj.kind === 'collect-info' && cj.state === 'done' ? json({}) : errResp('not_found', `任务 ${body.job} 不是做完了的收集问题信息`); + } + if (!['root', 'logs', 'instance'].includes(body.what)) return errResp('invalid_argument', `what 只能是 root、logs、instance 或 collect-info(给的是 "${body.what}")`); + return json({}); + } + if (p === '/api/components' && method === 'GET') { + if (db.diagMode === 'components-error') return errResp('internal', '组件状态暂不可用', '稍后重试'); + return json({ root: ROOT, components: checkResult().components }); + } + if (p === '/api/components/license' && method === 'GET') { + await sleep(400); + const lic = mockLicense(url.searchParams.get('source') || db.componentSource.source, url.searchParams.getAll('mirror_urls')); + return lic.error ? errResp(...lic.error) : json(lic); + } + if (p === '/api/components/source' && method === 'GET') return json({ ...db.componentSource, mirror_urls: [...db.componentSource.mirror_urls] }); + if (p === '/api/components/source' && method === 'PUT') { + const source = String(body.source || '').toLowerCase(); + const urls = Array.isArray(body.mirror_urls) ? body.mirror_urls.map((x) => String(x).trim()).filter(Boolean) : []; + if (!['auto', 'googledownloads_cn', 'tencent', 'google', 'custom'].includes(source)) return errResp('invalid_argument', '组件下载源不认识'); + if (source === 'custom' && !urls.length) return errResp('usage', '自定义组件下载源至少需要一个地址'); + if (source === 'custom' && urls.some((x) => !/^https?:\/\//i.test(x))) return errResp('invalid_argument', '自定义组件下载源必须是 HTTP(S) 地址'); + const normalized = urls.map((x) => x.endsWith('/') ? x : x + '/'); + db.componentSource = { source, mirror: source === 'custom' ? '' : source === 'auto' ? '' : source, mirror_urls: normalized, configured: true }; + return json({ ...db.componentSource, mirror_urls: [...normalized] }); + } + if (p === '/api/components/fetch' && method === 'POST') { + if (body.accept_license !== true) return errResp('usage', '补全组件必须明确同意许可协议'); + const lic = mockLicense(body.source || db.componentSource.source, body.mirror_urls || db.componentSource.mirror_urls); + if (lic.error || body.license_sha256 !== lic.license_sha256) return errResp('usage', '补全组件要带上看过的许可协议的 license_sha256'); + if (db.jobs.some((j) => j.kind === 'components' && j.state === 'running')) return errResp('busy', '组件补全正在进行'); + const j = newJob('components', -1); runDiag(j, 'components'); return json(j, 202); + } + if (p === '/api/diag/collect' && method === 'POST') { + if (db.jobs.some((j) => j.kind === 'collect-info' && j.state === 'running')) return errResp('busy', '收集问题信息正在进行'); + const j = newJob('collect-info', -1); runDiag(j); return json(j, 202); + } + if (p === '/api/diag/report-config' && method === 'GET') return json({ text: `ModAVD mock 配置信息\nroot=${ROOT}` }); if (p === '/api/check') { await sleep(url.searchParams.get('refresh') ? 2400 : 1300); return json(checkResult()); @@ -699,10 +800,10 @@ async function handle(url, init) { const j = db.jobs.find((x) => x.id === decodeURIComponent(m[1])); if (!j) return errResp('not_found', `没有任务 ${m[1]}`); if (m[2] && method === 'POST') { - if (j.state === 'running' && j.kind === 'start') { + if (j.state === 'running' && (j.kind === 'start' || j.kind === 'components' || j.kind === 'collect-info')) { j.state = 'canceled'; j.finished_at = iso(); - j.error = { code: 'canceled', message: '已停止等待,模拟器继续开机', advice: '' }; + j.error = { code: 'canceled', message: j.kind === 'start' ? '已停止等待,模拟器继续开机' : '已取消后台任务', advice: '' }; emit('job', j); log('warn', `实例 ${j.index} 已停止等待开机,模拟器继续开机`, j.id); } @@ -935,6 +1036,7 @@ export function install(scenario, params) { db.drop = params.get('drop') === '1'; db.shotfail = params.get('shotfail') === '1'; db.proxyfail = params.get('proxyfail') === '1'; + db.diagMode = params.get('diag') || (scenario === 'components-fail' ? 'components-fail' : scenario === 'diag-fail' ? 'diag-fail' : ''); window.__mockShots = {}; setup(scenario); const real = window.fetch.bind(window); diff --git a/internal/ui/web/js/actions.js b/internal/ui/web/js/actions.js index fa3048d..3ffb6bb 100644 --- a/internal/ui/web/js/actions.js +++ b/internal/ui/web/js/actions.js @@ -4,9 +4,10 @@ import { api } from './api.js'; import { S, update, instance } from './store.js'; import { toast, toastError, copyText } from './ui.js'; import { quoted, dur, diskInfoText, diskTrimText, diskCompactText } from './format.js'; -import { openActivity } from './panels.js'; +import { openActivity, runCheck } from './panels.js'; -const VERB = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', 'disk-info': '查看磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘' }; +const VERB = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', 'disk-info': '查看磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘', + components: '补全组件', 'collect-info': '收集问题信息' }; const isBoot = (j) => j.kind === 'start'; const activity = { label: '查看活动', run: () => openActivity() }; // wraps an API action: errors become a toast with the message and advice @@ -18,6 +19,8 @@ export function onJob(job) { const prev = S.jobs.get(job.id); if (prev && job.state === 'running' && (prev.state !== 'running' || (prev.progress && job.progress && job.progress.done < prev.progress.done))) return; S.jobs.set(job.id, job); + // components were just installed: 电脑检查 and the banner read the root again (the server dropped its cached check) + if (job.kind === 'components' && job.state === 'done' && prev && prev.state === 'running') runCheck(true); if (job.state !== 'running' && S.watch.delete(job.id)) notify(job); if (S.jobs.size > 80) { const old = [...S.jobs.values()].filter((j) => j.state !== 'running').sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at)); @@ -26,8 +29,11 @@ export function onJob(job) { update(); } +export function notifyJob(job) { notify(job); } + function notify(job) { - const who = quoted(instance(job.index), job.index); + // components / collect-info belong to the root (index -1), not to an instance + const who = job.index < 0 ? '' : quoted(instance(job.index), job.index); const r = job.result || {}; const e = job.error || {}; if (job.state === 'failed') toast({ tone: 'err', title: `${who}${VERB[job.kind] || ''}没有成功`, text: e.message, advice: e.advice, action: activity }); @@ -36,7 +42,9 @@ function notify(job) { const w = Math.max((r.warnings || []).length, (job.warnings || []).length); toast({ tone: w ? 'warn' : 'ok', title: `${who}已开机`, action: w ? activity : null, text: [r.first_boot && '首次初始化已完成', r.total_seconds && `用时 ${dur(r.total_seconds)}`, w && `有 ${w} 个设置步骤没成功`].filter(Boolean).join(',') }); - } else if (job.kind === 'create') toast({ title: `${who}新建好了`, text: '初始化已做完,已关机;第一次开机和平常一样快。' }); + } else if (job.kind === 'components') toast({ title: '组件补全完成', text: '已重新检查这台电脑。' }); + else if (job.kind === 'collect-info') toast({ title: '问题信息已收集', text: r.zip_path ? `问题包:${r.zip_path}` : '', timeout: 9000 }); + else if (job.kind === 'create') toast({ title: `${who}新建好了`, text: '初始化已做完,已关机;第一次开机和平常一样快。' }); else if (job.kind === 'stop') toast({ title: `${who}已关机` }); else if (job.kind === 'install') toast({ title: '安装完成', text: `${who}装好了 ${r.package || 'APK'}` }); // the sizes are the answer, so they stay long enough to read diff --git a/internal/ui/web/js/diagnostics.js b/internal/ui/web/js/diagnostics.js new file mode 100644 index 0000000..cb3d9c4 --- /dev/null +++ b/internal/ui/web/js/diagnostics.js @@ -0,0 +1,270 @@ +// 补全组件: the license dialog (from 电脑检查's 组件 item, the banner and its retry), and 设置 → 组件与问题反馈: +// component status, 收集问题信息, 复制配置信息. Everything runs as API jobs; closing a dialog does not stop them. + +import { h, icon, btn, setText, showError } from './dom.js'; +import { S, update, subscribe, newestFirst } from './store.js'; +import { api } from './api.js'; +import { openDialog, toast, toastError, copyText } from './ui.js'; +import { openCheck } from './panels.js'; +import * as A from './actions.js'; + +// before a package exists; afterwards the panel shows the note that came with it (what is inside, what may be shared) +const PRIVACY_BEFORE = '问题包只保存在这台电脑上,不会自动上传。生成以后,这里会写明包里有什么、哪些可以公开、哪些不要公开。'; + +const SOURCES = [['auto', '自动'], ['googledownloads_cn', 'googledownloads_cn'], ['tencent', 'tencent'], ['google', 'google'], ['custom', '自定义地址']]; + +export function loadDiagnostics() { + return api.get('/api/components').then((v) => { S.components = v; S.componentsError = null; update(); }).catch((e) => { + S.componentsError = e; + update(); + throw e; + }); +} + +export const activeJob = (kind) => [...S.jobs.values()].filter((j) => j.kind === kind && j.state === 'running').sort(newestFirst)[0] || null; +export const latestJob = (kind) => [...S.jobs.values()].filter((j) => j.kind === kind).sort(newestFirst)[0] || null; + +// The progress line of a components / collect-info job: the backend's own line when there is one (a download's +// already has the percentage, the sizes, the speed and the time left), else the step with the numbers. +export function progressText(j) { + const x = j && j.progress; + if (!x) return '正在准备…'; + const m = (x.message || '').trim().replace(/\s{2,}/g, ' '); + if (m) return m; + const bits = [x.name || x.step || '进行中']; + if (x.total > 0) { + const percent = Math.max(0, Math.min(100, Math.round((x.done || 0) * 100 / x.total))); + bits.push(`${percent}%(${Math.round((x.done || 0) / 1048576)} / ${Math.round(x.total / 1048576)} MB)`); + } + return bits.join(' · '); +} + +// a job just started from this window: its toast comes when it ends (the stream may have been faster than the POST) +function startJob(p) { + return p.then((j) => { + const seen = S.jobs.get(j.id); + if (seen && seen.state !== 'running') A.notifyJob(seen); + else { + S.watch.add(j.id); + A.onJob(j); + } + return j; + }); +} + +const splitURLs = (v) => v.split(',').map((x) => x.trim()).filter(Boolean); + +function licenseURL(source, urls) { + const q = new URLSearchParams({ source }); + for (const u of urls) q.append('mirror_urls', u); + return `/api/components/license?${q}`; +} + +let licenseCtl = null; + +// 补全组件: pick the source, read the license that source serves, agree, the job starts. The dialog opens at once +// with the saved source, so a source that cannot be reached shows its error here and another one can be picked. +export function openComponentFetch() { + if (licenseCtl) return; + const running = activeJob('components'); + if (running) { + toast({ tone: 'info', title: '组件正在补全', text: progressText(running) }); + return; + } + const select = h('select', { class: 'input', id: 'lic-src' }, ...SOURCES.map(([value, text]) => h('option', { value, text }))); + const custom = h('input', { class: 'input', id: 'lic-urls', placeholder: 'https://example/AndroidSDK/', spellcheck: 'false', autocomplete: 'off' }); + const customLabel = h('label', { class: 'f-label', for: 'lic-urls', text: '自定义地址' }); + const customCtl = h('div', { class: 'f-ctl' }, custom, + h('p', { class: 'f-hint', text: '一个或几个 HTTP(S) 目录地址,用逗号分开,按顺序试。不管从哪里下载,文件都按管理器里写好的校验值核对。' })); + const fields = h('div', { class: 'fields' }, + h('label', { class: 'f-label', for: 'lic-src', text: '下载源' }), h('div', { class: 'f-ctl' }, select), customLabel, customCtl); + const status = h('div', { class: 'form-error', role: 'alert', hidden: true }); + const note = h('p', { class: 'f-hint diag-license-from' }); + const text = h('pre', { class: 'loglines diag-license', tabindex: '0' }); + + let shown = null; // {sig, sha}: the text on screen, read from that source + let seq = 0; + const pick = () => { + const source = select.value; + const urls = source === 'custom' ? splitURLs(custom.value) : []; + return { source, urls, sig: `${source}|${urls.join(',')}` }; + }; + const fail = (e) => { + showError(status, e); + status.append(btn('btn btn-ghost btn-sm', [icon('refresh'), '重新获取'], () => { shown = null; load(); })); + }; + const load = async () => { + const p = pick(); + customLabel.hidden = customCtl.hidden = p.source !== 'custom'; + if (shown && shown.sig === p.sig) return; + const n = ++seq; + shown = null; + agree.disabled = true; + status.hidden = true; + text.hidden = true; + if (p.source === 'custom' && !p.urls.length) { + setText(note, '填好自定义地址后,这里显示从它取到的许可协议全文。'); + return; + } + setText(note, '正在从这个下载源取许可协议…'); + try { + const v = await api.get(licenseURL(p.source, p.urls)); + if (n !== seq) return; // another choice was made meanwhile + if (!v.license || !v.license_sha256) throw new Error('后台没有返回许可协议全文'); + setText(text, v.license); + text.hidden = false; + text.scrollTop = 0; + setText(note, `下面是从 ${v.license_source || '这个下载源'} 取到的全文。同意后从同一个下载源下载;到时取到的协议和这份不一样,就什么也不下。`); + shown = { sig: p.sig, sha: v.license_sha256 }; + agree.disabled = false; + } catch (e) { + if (n !== seq) return; + setText(note, '没有取到这个下载源的许可协议,可以换一个下载源。'); + fail(e); + } + }; + const agree = btn('btn btn-primary', '同意并下载', async () => { + const p = pick(); + if (!shown || shown.sig !== p.sig) { load(); return; } // the choice changed after the text was read + agree.disabled = true; + status.hidden = true; + const body = { source: p.source, mirror_urls: p.urls }; + try { + await api.put('/api/components/source', body); + await startJob(api.post('/api/components/fetch', { accept_license: true, ...body, license_sha256: shown.sha })); + d.close(); + } catch (e) { + agree.disabled = false; + fail(e); + } + }); + select.addEventListener('change', load); + custom.addEventListener('change', load); + custom.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); load(); } }); + + const d = openDialog({ + title: 'Android 软件开发套件许可协议', sub: '组件由 Google 提供。先选下载源,读完从它取到的许可协议再决定。', cls: 'dlg-update', light: true, + body: h('div', { class: 'dlg-body diag-license-body' }, fields, status, note, text), + actions: { left: btn('btn btn-ghost', '不同意', () => d.close()), cancel: false, ok: agree }, + }); + licenseCtl = d; + d.onClose.push(() => { licenseCtl = null; seq++; }); + agree.disabled = true; + api.get('/api/components/source').catch(() => ({})).then((saved) => { + if (licenseCtl !== d) return; // closed meanwhile + select.value = SOURCES.some(([v]) => v === saved.source) ? saved.source : 'auto'; + custom.value = (saved.mirror_urls || []).join(','); + load(); + }); +} + +let ctl = null; + +export function openDiagnostics() { + if (ctl) return; + const componentBox = h('div', { class: 'diag-components' }); + const componentError = h('div', { class: 'form-error', role: 'alert', hidden: true }); + const toCheck = btn('btn btn-ghost', [icon('shield'), '去电脑检查补全'], () => { ctl.close(); openCheck(); }); + const compHead = h('div', { class: 'diag-head' }, h('strong', { text: '组件' }), toCheck); + const compNote = h('p', { class: 'f-hint', text: '缺组件时,在「电脑检查」的组件一项点「补全组件」:先选下载源、读许可协议,同意后才下载。' }); + const componentState = h('p', { class: 'diag-state', hidden: true }); + + const collectBtn = btn('btn btn-secondary', [icon('file'), '收集问题信息'], () => startJob(api.post('/api/diag/collect', {})).catch(toastError)); + const configBtn = btn('btn btn-ghost', [icon('copy'), '复制配置信息'], async () => { + try { + const v = await api.get('/api/diag/report-config'); + const text = v.text || ''; + openConfig(text, await copyText(text)); + } catch (e) { toastError(e); } + }); + const feedbackHead = h('div', { class: 'diag-head' }, h('strong', { text: '问题反馈' }), h('div', { class: 'diag-actions' }, collectBtn, configBtn)); + let collectID = ''; + const collectText = h('span'); + const collectRunning = h('p', { class: 'diag-running', hidden: true }, h('span', { class: 'spin' }), collectText, + btn('btn btn-ghost btn-sm', '取消', () => collectID && A.cancelJob(collectID))); + const collectResult = h('div', { class: 'diag-result' }); + let collectSig = null; + const privacy = h('p', { class: 'f-hint diag-privacy', text: PRIVACY_BEFORE }); + + ctl = openDialog({ title: '组件与问题反馈', sub: '组件状态、收集问题信息和复制配置信息', cls: 'dlg-update', light: true, + body: h('div', { class: 'dlg-body' }, compHead, compNote, componentError, componentBox, componentState, feedbackHead, collectRunning, collectResult, privacy) }); + + let compSig = null; + const render = () => { + const c = S.components; + const sig = c && Array.isArray(c.components) ? JSON.stringify(c.components) : ''; + if (sig !== compSig) { + compSig = sig; + componentBox.replaceChildren(...(c && Array.isArray(c.components) ? c.components : []).map((x) => h('div', { class: 'comp-row' }, + h('span', null, x.name || x.id, x.optional && h('span', { class: 'muted', text: '(可选)' })), + h('span', { class: 'v', text: x.version || x.build || '' }), + h('span', { class: 'pill tone-' + (x.installed ? 'ok' : x.optional ? 'idle' : 'err'), text: x.installed ? '已安装' : x.optional ? '未安装' : '缺少' })))); + } + if (S.componentsError) showError(componentError, S.componentsError); + componentError.hidden = !S.componentsError; + + const fj = activeJob('components'); + const last = fj || latestJob('components'); + const missing = !!(c && Array.isArray(c.components) && c.components.some((x) => !x.optional && !x.installed)); + toCheck.hidden = !missing && !fj && last?.state !== 'failed'; + let state = ''; + let tone = ''; + if (fj) state = `正在补全组件:${progressText(fj)}`; + else if (last?.state === 'done') { + // the list above is from before: read it again once per finished job + if (ctl.refreshedComponentsJob !== last.id && ctl.refreshingComponentsJob !== last.id) { + ctl.refreshingComponentsJob = last.id; + loadDiagnostics().catch(() => {}).finally(() => { if (ctl) { ctl.refreshedComponentsJob = last.id; update(); } }); + } + [state, tone] = ['组件补全完成。', 'tone-ok']; + } else if (last?.state === 'failed') [state, tone] = [`上次补全组件没有成功:${last.error?.message || '原因未知'}`, 'tone-err']; + else if (last?.state === 'canceled') [state, tone] = ['上次补全组件已取消。', 'tone-idle']; + setText(componentState, state); + componentState.className = 'diag-state ' + tone; + componentState.hidden = !state; + + const cj = activeJob('collect-info'); + collectBtn.disabled = !!cj; + collectRunning.hidden = !cj; + if (cj) { + collectID = cj.id; + setText(collectText, `收集问题信息:${progressText(cj)}`); + } + const done = cj ? null : latestJob('collect-info'); + const rsig = done ? `${done.id}:${done.state}` : ''; + if (rsig !== collectSig) { + collectSig = rsig; + collectResult.replaceChildren(...collectRows(done)); + setText(privacy, done?.state === 'done' && done.result?.privacy_note ? done.result.privacy_note : PRIVACY_BEFORE); + } + }; + const un = subscribe(render); + ctl.onClose.push(() => { un(); ctl = null; }); + loadDiagnostics().catch(() => {}); + render(); +} + +function collectRows(j) { + if (!j) return []; + if (j.state === 'failed') return [h('p', { class: 'tone-err', text: j.error?.message || '收集问题信息没有成功。' })]; + if (j.state === 'canceled') return [h('p', { class: 'tone-idle', text: '已取消收集问题信息。' })]; + const r = j.result; + if (j.state !== 'done' || !r) return []; + const rows = [h('p', { class: 'diag-path', text: `问题包:${r.zip_path || '已生成'}${r.zip_size ? `(${r.zip_size})` : ''}` }), + h('div', { class: 'diag-actions' }, btn('btn btn-ghost', [icon('folder'), '在资源管理器里显示'], + () => api.post('/api/open', { what: 'collect-info', job: j.id }).catch(toastError)))]; + if (r.sections?.length) rows.push(h('ul', { class: 'diag-sections' }, ...r.sections.map((x) => h('li', { class: x.error ? 'tone-err' : 'tone-ok', text: `${x.name}:${x.error || '完成'}` })))); + else if (r.problems?.length) rows.push(h('p', { class: 'f-hint', text: `有 ${r.problems.length} 节没收集成功,原因写在包里的 summary.txt,照样提交就行。` })); + if (r.mas_found === false) rows.push(h('p', { class: 'f-hint', text: '没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志。' })); + return rows; +} + +function openConfig(text, copied) { + const pre = h('pre', { class: 'loglines diag-config', text }); + const status = h('p', { class: copied ? 'tone-ok' : 'tone-err', text: copied ? '已复制到剪贴板。' : '没能自动复制,请点击“复制”按钮或手动选中。' }); + const copy = btn('btn btn-primary', '复制', async () => { + if (await copyText(text)) toast({ title: '已复制' }); + else toast({ tone: 'err', title: '没能复制', text: '请手动选中配置信息复制。' }); + }); + openDialog({ title: '配置信息', sub: '复制前请检查内容中是否包含不适合公开的信息', cls: 'dlg-update', body: h('div', { class: 'dlg-body' }, status, pre), actions: { ok: copy }, light: true }); +} diff --git a/internal/ui/web/js/format.js b/internal/ui/web/js/format.js index 4962633..24b2af8 100644 --- a/internal/ui/web/js/format.js +++ b/internal/ui/web/js/format.js @@ -1,7 +1,7 @@ // Formatting for people (zh-CN). -export const KIND = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', 'disk-info': '磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘' }; -export const KIND_ICON = { start: 'power', stop: 'stop', install: 'download', create: 'plus', 'disk-info': 'disk', 'disk-trim': 'disk', 'disk-compact': 'disk' }; +export const KIND = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', components: '补全组件', 'collect-info': '收集问题信息', 'disk-info': '磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘' }; +export const KIND_ICON = { start: 'power', stop: 'stop', install: 'download', create: 'plus', components: 'download', 'collect-info': 'file', 'disk-info': 'disk', 'disk-trim': 'disk', 'disk-compact': 'disk' }; export const STATE = { running: '进行中', done: '完成', failed: '失败', canceled: '已取消' }; export const STATE_TONE = { running: 'warn', done: 'ok', failed: 'err', canceled: 'idle' }; diff --git a/internal/ui/web/js/panels.js b/internal/ui/web/js/panels.js index 8391de9..e84fc40 100644 --- a/internal/ui/web/js/panels.js +++ b/internal/ui/web/js/panels.js @@ -4,6 +4,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 { openDialog } from './ui.js'; +import { openComponentFetch, activeJob, latestJob, progressText } from './diagnostics.js'; import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, parseTime, pct, stripInst, stopMethod, diskInfoText, diskTrimText, diskCompactText } from './format.js'; import * as A from './actions.js'; @@ -11,8 +12,14 @@ const advice = (text) => h('div', { class: 'advice' }, icon('bulb'), h('span', { const folderBtn = (cls) => btn(cls, [icon('folder'), '打开目录'], () => A.openFolder('root')); // ---- check +let refreshAgain = 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) { - if (S.checking) return; + if (S.checking) { + if (refresh) refreshAgain = true; + return; + } S.checking = true; S.checkError = null; update(); @@ -24,6 +31,10 @@ export async function runCheck(refresh) { } S.checking = false; update(); + if (refreshAgain) { + refreshAgain = false; + runCheck(true); + } } export function checkSummary(c) { @@ -45,11 +56,43 @@ function itemView(it) { const RANK = (it) => (it.ok === false ? (it.blocking ? 0 : 1) : it.ok == null ? 2 : 3); function checkRow() { - const r = { ic: h('span', { class: 'ci-ic' }), title: h('span'), tag: h('span', { class: 'tag', text: '不影响开机' }), reason: h('div', { class: 'ci-reason' }), adv: h('div'), state: h('span', { class: 'ci-state' }) }; + const r = { ic: h('span', { class: 'ci-ic' }), title: h('span'), tag: h('span', { class: 'tag', text: '不影响开机' }), reason: h('div', { class: 'ci-reason' }), adv: h('div', { class: 'ci-advice' }), state: h('span', { class: 'ci-state' }) }; r.el = h('li', { class: 'check-item' }, r.ic, h('div', null, h('div', { class: 'ci-title' }, r.title, r.tag), r.reason, r.adv), r.state); return r; } +// 补全组件 under the 组件 item when it did not pass: the button (license dialog), then the running job's progress +// with 取消, then, when the last fetch failed, why and 重试. Built once per row and only updated. +function fetchBox() { + const b = { id: '' }; + b.go = btn('btn btn-secondary btn-sm', null, openComponentFetch); + b.text = h('span'); + b.running = h('div', { class: 'diag-running', role: 'status', hidden: true }, h('span', { class: 'spin' }), b.text, + btn('btn btn-ghost btn-sm', '取消', () => b.id && A.cancelJob(b.id))); + b.err = h('div', { class: 'form-error', role: 'alert', hidden: true }); + b.el = h('div', { class: 'ci-fetch' }, b.running, b.err, b.go); + return b; +} + +function fillFetchBox(b) { + const run = activeJob('components'); + const last = run ? null : latestJob('components'); + const failed = last && last.state === 'failed' ? last : null; + b.running.hidden = !run; + b.go.hidden = !!run; + if (run) { + b.id = run.id; + setText(b.text, progressText(run)); + } + const sig = failed ? failed.id : ''; + if (b.sig !== sig) { + b.sig = sig; + if (failed) showError(b.err, { message: `补全组件没有成功:${failed.error?.message || '原因未知'}`, advice: failed.error?.advice }); + b.err.hidden = !failed; + b.go.replaceChildren(icon(failed ? 'refresh' : 'download'), failed ? '重试' : '补全组件'); + } +} + function fillCheckRow(r, it) { const [tone, ic, word] = itemView(it); r.el.className = 'check-item tone-' + tone; @@ -58,7 +101,14 @@ function fillCheckRow(r, it) { r.tag.hidden = it.blocking; setText(r.reason, it.reason); const a = it.ok !== true && it.advice ? it.advice : ''; - if (r.a !== a) r.adv.replaceChildren((r.a = a) ? advice(a) : ''); + const needsFetch = it.id === 'components' && it.ok === false; + if (needsFetch && !r.fetch) r.fetch = fetchBox(); + if (r.a !== a || r.fetchShown !== needsFetch) { + r.a = a; + r.fetchShown = needsFetch; + r.adv.replaceChildren(a ? advice(a) : '', needsFetch ? r.fetch.el : ''); + } + if (needsFetch) fillFetchBox(r.fetch); setText(r.state, word); } @@ -140,7 +190,7 @@ export function openCheck() { ...cs.map((x) => h('div', { class: 'comp-row' }, h('span', null, x.name, x.optional && h('span', { class: 'muted', text: '(可选)' })), h('span', { class: 'v', text: [x.version, x.build].filter(Boolean).join(' · ') }), - h('span', { class: 'pill tone-' + (x.installed ? 'ok' : x.optional ? 'idle' : 'err'), text: x.installed ? '已安装' : '缺少' })))); + h('span', { class: 'pill tone-' + (x.installed ? 'ok' : x.optional ? 'idle' : 'err'), text: x.installed ? '已安装' : x.optional ? '未安装' : '缺少' })))); } }; const un = subscribe(render); @@ -165,11 +215,15 @@ export function renderBanner(el) { h('div', { class: 'banner-main' }, h('h2', { text: '这个目录现在还不能开机' }), first && h('p', { text: first.reason }), - first && first.advice && advice(first.advice), + first && first.advice && advice(first.id === 'components' + ? '请点下面的「补全组件」打开许可协议并下载,或运行 tools\\modavd.exe components fetch。' + : first.advice), missing.length && !(first && first.id === 'components') && h('div', { class: 'banner-missing' }, h('span', { class: 'muted', text: '缺少的组件:' }), missing.map((m) => h('span', { class: 'chip-s', text: m.name }))), blockers.length > 1 && h('p', { class: 'banner-more', text: `另外还有 ${blockers.length - 1} 项必需检查没通过,详情见电脑检查。` }), - h('div', { class: 'banner-actions' }, btn('btn btn-secondary', [icon('shield'), '查看电脑检查'], openCheck, { id: 'bannerCheck' }), folderBtn('btn btn-ghost'))))); + h('div', { class: 'banner-actions' }, + (first?.id === 'components' || missing.length) && btn('btn btn-secondary', [icon('download'), '补全组件'], openComponentFetch), + btn('btn btn-secondary', [icon('shield'), '查看电脑检查'], openCheck, { id: 'bannerCheck' }), folderBtn('btn btn-ghost'))))); } // ---- activity @@ -245,11 +299,14 @@ function jobRow(j) { } function updateJob(r, j) { - const inst = instance(j.index); - const name = inst ? label(inst) : `实例 ${j.index}`; + // 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}`; if (r.name !== name) { r.name = name; - r.title.replaceChildren(`${KIND[j.kind] || j.kind}「${name}」`, inst && name !== inst.name && h('span', { class: 'muted', text: inst.name })); + r.title.replaceChildren(...[rootJob ? KIND[j.kind] || j.kind : `${KIND[j.kind] || j.kind}「${name}」`, + inst && name !== inst.name && h('span', { class: 'muted', text: inst.name })].filter(Boolean)); } const running = j.state === 'running'; const t0 = parseTime(j.created_at); @@ -268,9 +325,10 @@ function updateJob(r, j) { r.bar.classList.toggle('indet', n == null); r.fill.style.width = n == null ? '' : n.toFixed(1) + '%'; } - r.acts.hidden = !(running && (j.kind === 'start' || j.kind === 'disk-compact')); - setText(r.cancel, j.kind === 'disk-compact' ? '取消整理' : '取消等待'); - r.cancelHint.hidden = j.kind === 'disk-compact'; + const cancelBackend = j.kind === 'components' || j.kind === 'collect-info'; + r.acts.hidden = !(running && (j.kind === 'start' || j.kind === 'disk-compact' || cancelBackend)); + setText(r.cancel, j.kind === 'disk-compact' ? '取消整理' : cancelBackend ? '取消' : '取消等待'); + r.cancelHint.hidden = j.kind === 'disk-compact' || cancelBackend; // 已取消: say why (stopped waiting, or a 关机 cut the boot short) const why = j.state === 'canceled' && j.error ? j.error.message || '' : ''; @@ -316,6 +374,8 @@ function resultText(j) { if (j.kind === 'disk-info') return diskInfoText(r); if (j.kind === 'disk-trim') return diskTrimText(); if (j.kind === 'disk-compact') return diskCompactText(r); + if (j.kind === 'components') return '组件补全完成。'; + if (j.kind === 'collect-info') return r.zip_path ? `问题包:${r.zip_path}` : '问题包已生成。'; if (!r.launched) return '模拟器本来就在运行,只重做了开机后的设置。'; return [`安卓开机 ${dur(r.boot_seconds || 0)},共 ${dur(r.total_seconds || 0)}`, r.first_boot && '完成了首次初始化', r.zygote_restarted && '装了 Vulkan 补丁(安卓框架重启过一次)'].filter(Boolean).join(',') + '。'; diff --git a/internal/ui/web/js/store.js b/internal/ui/web/js/store.js index 9db6701..0d23e76 100644 --- a/internal/ui/web/js/store.js +++ b/internal/ui/web/js/store.js @@ -12,6 +12,8 @@ export const S = { checkError: null, checkedAt: 0, update: null, // GET /api/update and the "update" event + components: null, // GET /api/components + componentsError: null, conn: 'connecting', // connecting | open | lost | auth lostAt: 0, // when the event stream went down (0 while it is up) pending: {}, // index -> kind while the POST is in flight diff --git a/internal/ui/web/js/ui.js b/internal/ui/web/js/ui.js index ee83ef5..2a0dbda 100644 --- a/internal/ui/web/js/ui.js +++ b/internal/ui/web/js/ui.js @@ -67,7 +67,8 @@ export function openDialog(o) { class: 'dlg ' + (o.cls || ''), role: o.alert ? 'alertdialog' : 'dialog', 'aria-modal': 'true', 'aria-labelledby': id + 't', 'aria-describedby': o.desc || (o.sub && id + 's'), }, h('header', { class: 'dlg-head' }, o.lead, h('div', { class: 'dlg-titles' }, titleEl, subEl), o.tools, ibtn('x', '关闭', () => close(), 'icon-btn dlg-x'))); - const parts = [o.body, o.actions ? h('footer', { class: 'dlg-foot' }, o.actions.left, btn('btn btn-ghost', '取消', () => close()), o.actions.ok) : o.foot]; + const parts = [o.body, o.actions ? h('footer', { class: 'dlg-foot' }, o.actions.left, + o.actions.cancel === false ? null : btn('btn btn-ghost', '取消', () => close()), o.actions.ok) : o.foot]; let form = null; if (o.form) { el.append((form = h('form', { class: 'dlg-form', novalidate: true }, parts))); diff --git a/internal/ui/web/js/update.js b/internal/ui/web/js/update.js index acc7876..9404380 100644 --- a/internal/ui/web/js/update.js +++ b/internal/ui/web/js/update.js @@ -6,6 +6,7 @@ import { h, icon, btn, attr, setText, showError } from './dom.js'; import { S, update, subscribe } from './store.js'; import { api } from './api.js'; import { openDialog, toast, toastError } from './ui.js'; +import { openDiagnostics } from './diagnostics.js'; const clock = (t) => new Date(t).toLocaleString('zh-CN', { hour12: false }); const ver = (v) => (v ? v.replace(/^v/, '') : ''); @@ -44,11 +45,50 @@ export function openUpdate() { const cdkState = h('p', { class: 'f-hint' }); const save = btn('btn btn-secondary', '保存 CDK', () => cdk.value.trim() && post({ cdk: cdk.value.trim() }, '已保存 CDK')); const clear = btn('btn btn-ghost', '清除', () => post({ cdk: '' }, '已清除 CDK')); + const componentSource = h('select', { class: 'input', id: 'upd-comp' }, + ...[['auto', '自动'], ['googledownloads_cn', 'googledownloads_cn'], ['tencent', 'tencent'], ['google', 'google'], ['custom', '自定义地址']] + .map(([value, text]) => h('option', { value, text }))); + const componentURLs = h('input', { class: 'input', placeholder: 'https://example/AndroidSDK/', spellcheck: 'false', 'aria-label': '自定义下载地址' }); + // a built-in choice is saved when picked (like 更新源); 自定义地址 when its addresses are saved + let savedSource = 'auto'; + const showSaved = (v) => { + savedSource = v.source || 'auto'; + componentSource.value = savedSource; + componentURLs.value = (v.mirror_urls || []).join(','); + componentURLsRow.hidden = savedSource !== 'custom'; + }; + async function saveComponentSource() { + const source = componentSource.value; + const urls = source === 'custom' ? componentURLs.value.split(',').map((x) => x.trim()).filter(Boolean) : []; + if (source === 'custom' && !urls.length) { + toast({ tone: 'err', title: '需要自定义地址', text: '请填写至少一个 HTTP(S) 下载目录。' }); + return; + } + try { + const v = await api.put('/api/components/source', { source, mirror_urls: urls }); + showSaved(v); + toast({ title: '已保存组件下载源', text: source === 'custom' ? (v.mirror_urls || []).join('、') : source === 'auto' ? '自动' : source }); + } catch (e) { + toastError(e); + if (source !== 'custom') { componentSource.value = savedSource; componentURLsRow.hidden = savedSource !== 'custom'; } + } + } + const componentSave = btn('btn btn-secondary', '保存', saveComponentSource); + const componentURLsRow = h('div', { class: 'upd-cdk upd-urls' }, componentURLs, componentSave); + componentSource.addEventListener('change', () => { + componentURLsRow.hidden = componentSource.value !== 'custom'; + if (componentSource.value !== 'custom') saveComponentSource(); + else componentURLs.focus(); + }); + componentURLs.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); saveComponentSource(); } }); const fields = h('div', { class: 'fields' }, h('span', { class: 'f-label', id: 'upd-src-l', text: '更新源' }), h('div', { class: 'f-ctl' }, seg), h('label', { class: 'f-label', for: 'upd-cdk', text: 'Mirror 酱 CDK' }), - h('div', { class: 'f-ctl' }, h('div', { class: 'upd-cdk' }, cdk, save, clear), cdkState)); + h('div', { class: 'f-ctl' }, h('div', { class: 'upd-cdk' }, cdk, save, clear), cdkState), + h('label', { class: 'f-label', for: 'upd-comp', text: '组件下载源' }), h('div', { class: 'f-ctl' }, componentSource, componentURLsRow, + h('p', { class: 'f-hint', text: '「补全组件」和 modavd components fetch 都从这里选的源下载;选了就保存。' }))); const note = h('p', { class: 'f-hint', text: '更新现在由 AUTO-MAS 发起(它调用 modavd update),会按这里选的更新源和 CDK 下载;这里先不能直接更新。' }); + const diagBtn = btn('btn btn-secondary', [icon('shield'), '组件与问题反馈'], () => { ctl.close(); openDiagnostics(); }); const when = h('span', { class: 'foot-note' }); const checkBtn = btn('btn btn-secondary', null, () => run(api.post('/api/update/check'))); const installBtn = btn('btn btn-primary', [icon('download'), '更新'], null, @@ -57,7 +97,7 @@ export function openUpdate() { ctl = openDialog({ title: '设置', sub: 'ModAVD 更新:模拟器、组件、脚本和管理器整包更新', cls: 'dlg-update', light: true, - body: h('div', { class: 'dlg-body' }, msg, box, kv, fields, note), + body: h('div', { class: 'dlg-body' }, msg, box, kv, fields, note, h('div', { class: 'diag-launch' }, diagBtn)), foot: h('footer', { class: 'dlg-foot' }, when, checkBtn, installBtn), }); @@ -90,6 +130,7 @@ export function openUpdate() { setText(cdkState, v.cdk_set ? '已填(加密保存在本机,只有这个 Windows 用户能读)' : v.source === 'mirrorchyan' ? '没填:没有 CDK 时只能从 GitHub 下载' : '选 Mirror 酱时才用到'); cdk.placeholder = v.cdk_set ? '已填,输入新的会替换' : '粘贴 CDK'; clear.hidden = !v.cdk_set; + componentURLsRow.hidden = componentSource.value !== 'custom'; setText(when, v.checked_at ? `检查于 ${clock(v.checked_at)}` : ''); checkBtn.replaceChildren(v.checking ? h('span', { class: 'spin' }) : icon('refresh'), v.checking ? '正在检查…' : '检查更新'); checkBtn.disabled = !!v.checking; @@ -97,5 +138,6 @@ export function openUpdate() { }; const un = subscribe(render); ctl.onClose.push(() => { un(); ctl = null; }); + api.get('/api/components/source').then(showSaved).catch(() => {}); render(); } diff --git a/scripts/test_create_cancel.mjs b/scripts/test_create_cancel.mjs index be12876..3c6e352 100644 --- a/scripts/test_create_cancel.mjs +++ b/scripts/test_create_cancel.mjs @@ -22,7 +22,7 @@ async function harness() { 'forms.js': { openSettings: noop, openDelete: noop }, 'proxy.js': { openProxy: noop, markOf: noop }, 'viewer.js': { openViewer: noop, openLogs: noop }, - 'panels.js': { openActivity: noop }, + 'panels.js': { openActivity: noop, runCheck: noop }, }; async function load(name) { if (cache.has(name)) return cache.get(name);