diff --git a/README.en.md b/README.en.md index 55fe4ba..c9e19b5 100644 --- a/README.en.md +++ b/README.en.md @@ -96,6 +96,8 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] **Google Play Store** (`components fetch --play-store`, `components import-playstore`, `playstore`, ModAVD#125): +On Apple Silicon macOS, downloads and imports use Android 15 Google Play `arm64-v8a` r9: the official `arm64-v8a-35_r09.zip` archive (about 1.8 GB). After license consent, download it or import your own archive/five files; their pinned sizes and hashes are checked before publication to `sdk/playstore/`. These files are separate from the Windows API34 x86_64 profile described below. macOS currently only prepares the component; installation into an instance and removal remain unavailable, and no execution bridge is required to prepare the files. + - The instances' Google APIs image has Google Play services but no store. The store is five files of Google's official `system-images;android-34;google_apis_playstore;x86_64` r14, the same release as the instances' image: the store, the Google-signed Play services, GSF and Partner Setup, and the store's fs-verity certificate. The root folder keeps only these five files (`sdk\playstore\`), and each instance gets them when asked; new instances never do. - The root gets the five files one of two ways, both optional (`components status` and `check` never count them as required): - Download: `components fetch --play-store` (in the manager, 「补全 Google Play 商店服务」 in the component dialog) downloads that image (about 1.5 GB) after the license agreement, reads the five files out of its `system.img` read-only, each checked against its built-in size and SHA-256, and deletes the unpacked `system.img` and the downloaded zip (the zip stays with `--keep-downloads`). Skipped when the files are there already. diff --git a/README.md b/README.md index 2b90837..ff7be03 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] **Google Play 商店**(`components fetch --play-store`、`components import-playstore`、`playstore`,ModAVD#125): +Apple Silicon macOS 的下载与导入使用 Android 15 Google Play `arm64-v8a` r9:官方归档为 `arm64-v8a-35_r09.zip`(约 1.8 GB)。许可确认后下载,或导入自己的归档/五个文件,按该版本的大小和摘要校验后保存到 `sdk/playstore/`。它与下面的 Windows API34 x86_64 文件不混用。当前 macOS 仅准备组件,实例内安装与移除尚未开放,不要求安装执行桥。 + - 实例用的 Google APIs 镜像有 Play 服务、没有商店。商店取自 Google 官方的 `system-images;android-34;google_apis_playstore;x86_64` r14(和实例的镜像同一期)里的五个文件:商店、Google 签名的 Play 服务、GSF、合作伙伴设置和商店的 fs-verity 证书。根目录只留这五个文件(`sdk\playstore\`),每台实例要的时候再装进去;新建的实例不带。 - 根目录的五个文件有两种来法,都是可选的,`components status`、`check` 不把它们算进必需组件: - 下载:`components fetch --play-store`(管理器「补全组件」里勾选「补全 Google Play 商店服务」)同意许可协议后下载这个镜像(约 1.5 GB),从里面的 `system.img` 只读取出五个文件,每个都核对写死的大小和 SHA-256,然后删掉解出的 `system.img` 和下载的压缩包(`--keep-downloads` 时压缩包留着)。已经有了就跳过。 diff --git a/internal/cli/commands.go b/internal/cli/commands.go index a73aa3c..f5c6b55 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -86,7 +86,7 @@ var ( "不询问,直接视为同意《Android 软件开发套件许可协议》(自动化用;协议全文照样取回并打印,取不到照样退出);--json 时必须给"} optKeepDownloads = flagSpec{"keep-downloads", kindBool, "", "装好后保留 downloads 里的 zip(默认装好即删,省 1.5 GB)"} optFetchStore = flagSpec{"play-store", kindBool, "", - "也补全可选的 Google Play 商店:下 Google Play 版系统镜像(约 1.5 GB),只取出商店等 5 个文件(约 230 MB)"} + playStoreFetchHelp()} optMirror = flagSpec{"mirror", kindString, "ID", "只用一个内置镜像:googledownloads_cn / tencent / google"} optMirrorURL = flagSpec{"mirror-url", kindStrings, "URL", "自建或测试用:用这个目录地址代替内置的三个镜像,可写多次或用逗号隔开,按顺序试;给了就不看 --mirror"} @@ -133,7 +133,9 @@ func init() { "默认依次试 googledownloads.cn、腾讯云镜像、dl.google.com,下载约 1.8 GB,中断后再次运行会接着下;" + "校验归档、版本和平台后装进 sdk/,保留执行权限。已经齐全的跳过;不完整的旧目录改名为 <目录>.incomplete-<时间> 留着,不删。" + "SDK 使用中会拒绝补全,请先自行关闭使用它的进程。组件补全后,当前 macOS 版本仍未开放实例创建与启动。" + - "--play-store 和 import-playstore 目前仅支持 Windows。" + + "--play-store 可选下载 Android 15 Google Play arm64-v8a r9 镜像(约 1.8 GB),校验后只保留商店等五个文件。" + + "import-playstore 可导入官方 arm64-v8a-35_r09.zip 或五个文件,按固定大小和摘要检查,不下载。" + + "这两项仅准备文件,当前 macOS 版本尚未开放实例内安装与移除。" + "source 查看或修改记住的下载源(auto、googledownloads_cn、tencent、google);--mirror 或 --mirror-url 可覆盖本次选择。" + "--json 时必须给 --accept-license(仅 fetch),表示已同意待下载组件的全部许可协议。" + "退出码:未同意许可是 2(license_declined),未就绪或预检失败是 4,SDK 使用中或正在补全是 5,下载不可用或校验失败是 9。" diff --git a/internal/cli/playstore.go b/internal/cli/playstore.go index 18883a2..24c440d 100644 --- a/internal/cli/playstore.go +++ b/internal/cli/playstore.go @@ -3,11 +3,14 @@ package cli import ( "fmt" "io" + "path/filepath" + "runtime" "strings" "time" "modavd/internal/components" "modavd/internal/errs" + "modavd/internal/playstore" ) // importPlayStore is components.ImportPlayStore (a variable for the tests). @@ -28,7 +31,7 @@ func runImportPlayStore(c *cmdCtx) (any, error) { } paths := c.inv.pos[1:] if len(paths) == 0 { - return nil, errs.New(errs.Usage, "components import-playstore 要给文件或文件夹:Google Play 版镜像的压缩包 x86_64-34_r14.zip,或者商店的五个文件"). + return nil, errs.New(errs.Usage, "components import-playstore 要给文件或文件夹:Google Play 版镜像的压缩包 "+filepath.Base(playstore.CurrentProfile().ImageURL)+",或者商店的五个文件"). WithAdvice(findCommand("components").usageAdvice()) } files, err := components.PlayStoreImportFiles(paths) @@ -92,5 +95,16 @@ func formatImportPlayStore(w io.Writer, r *components.PlayStoreImportResult) { if r.SetAside != "" { fmt.Fprintf(w, " 原来不完整的目录改名为 %s,确认没用后可以自己删掉\n", r.SetAside) } - fmt.Fprintln(w, "给实例装上:modavd playstore <实例编号> install") + if runtime.GOOS == "darwin" { + fmt.Fprintln(w, "ARM64 商店文件已准备;当前 macOS 版本尚未开放实例内安装与移除") + } else { + fmt.Fprintln(w, "给实例装上:modavd playstore <实例编号> install") + } +} + +func playStoreFetchHelp() string { + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + return "也准备可选的 Google Play 商店:下载 API35 ARM64 r9 镜像(约 1.8 GB),校验后只保留五个文件;实例内装卸尚未开放" + } + return "也补全可选的 Google Play 商店:下 Google Play 版系统镜像(约 1.5 GB),只取出商店等 5 个文件(约 230 MB)" } diff --git a/internal/components/fetch.go b/internal/components/fetch.go index 8c6da22..f4f0864 100644 --- a/internal/components/fetch.go +++ b/internal/components/fetch.go @@ -333,9 +333,6 @@ func Fetch(ctx context.Context, r *root.Root, o Options) (*Result, error) { if fetchGOOS != "windows" && !(fetchGOOS == "darwin" && nativeDarwinComponents) { return nil, errs.New(errs.Unsupported, "这个系统还没有组件清单").WithAdvice("目前支持 Windows 和 Apple Silicon macOS") } - if o.PlayStore && fetchGOOS != "windows" { - return nil, errs.New(errs.Unsupported, "目前只有 Windows 能补全 Google Play 商店组件") - } if r == nil { return nil, errs.New(errs.NotFound, "找不到魔改 AVD 根目录") } diff --git a/internal/components/install.go b/internal/components/install.go index 2e2faf5..285c0a3 100644 --- a/internal/components/install.go +++ b/internal/components/install.go @@ -113,11 +113,16 @@ func (f *fetcher) installPlayStore(ctx context.Context, c root.SDKComponent, zip if err := f.playStoreFromZip(ctx, c, zipPath, staging, out); err != nil { return "", err } - return f.finishPlayStore(c, out, PlayStoreFromDownload) + return f.finishPlayStore(ctx, c, out, PlayStoreFromDownload) } // playStoreStaging is a fresh sdk\.staging\ and the directory in it the five files are gathered in. func (f *fetcher) playStoreStaging(c root.SDKComponent) (staging, out string, err error) { + if f.darwin() { + if err := f.platformPaths(); err != nil { + return "", "", err + } + } staging = filepath.Join(f.stagingRoot(), c.ID) f.removeStaging(staging) out = filepath.Join(staging, root.PlayStoreDir) @@ -131,9 +136,10 @@ func (f *fetcher) playStoreStaging(c root.SDKComponent) (staging, out string, er // staging\system.img, read without mounting anything (playStoreExtract checks each file's size and SHA-256) and // deleted again, whether that worked or not. func (f *fetcher) playStoreFromZip(ctx context.Context, c root.SDKComponent, zipPath, staging, out string) error { - f.where = "从 " + filepath.Base(zipPath) + " 解出 " + playstore.ImageMember + member := playstore.CurrentProfile().ImageMember + f.where = "从 " + filepath.Base(zipPath) + " 解出 " + member img := filepath.Join(staging, "system.img") - err := f.extractMember(ctx, c, zipPath, playstore.ImageMember, img) + err := f.extractMember(ctx, c, zipPath, member, img) if err == nil { f.where = "从 system.img 取出 Google Play 商店的文件" f.say(" 从系统镜像里取出商店、Play 服务、GSF、合作伙伴设置…") @@ -149,7 +155,12 @@ func (f *fetcher) playStoreFromZip(ctx context.Context, c root.SDKComponent, zip // finishPlayStore writes out's source.properties (with where the files came from), checks out as a whole (Complete: // every pin and the revision) and moves it into sdk\playstore. -func (f *fetcher) finishPlayStore(c root.SDKComponent, out, source string) (setAside string, err error) { +func (f *fetcher) finishPlayStore(ctx context.Context, c root.SDKComponent, out, source string) (setAside string, err error) { + if f.darwin() { + if err := f.platformPaths(); err != nil { + return "", err + } + } props := c.Properties + playStoreSourceKey + "=" + source + "\n" if err := os.WriteFile(filepath.Join(out, "source.properties"), []byte(props), 0o644); err != nil { return "", errs.Wrap(errs.Internal, err, "写不了 source.properties:"+err.Error()) @@ -157,6 +168,17 @@ func (f *fetcher) finishPlayStore(c root.SDKComponent, out, source string) (setA if !c.Complete(out) { return "", rejected("%s 的文件校验失败", c.FetchName) } + if ctx.Err() != nil { + return "", canceled(ctx) + } + if f.darwin() { + if err := f.platformPaths(); err != nil { + return "", err + } + if err := f.platformIdle(); err != nil { + return "", err + } + } return f.place(c, out) } @@ -168,28 +190,47 @@ func (f *fetcher) extractMember(ctx context.Context, c root.SDKComponent, zipPat return rejected("压缩包打不开:%v", err) } defer zr.Close() + if f.darwin() && len(zr.File) > 20000 { + return rejected("压缩包条目过多") + } + var member *zip.File for _, e := range zr.File { if strings.ReplaceAll(e.Name, `\`, "/") != name { continue } - if e.Flags&1 != 0 { - return rejected("压缩包里的 %s 是加密的", e.Name) + if member != nil { + return rejected("压缩包里的 %s 出现了两次", name) } - if e.Method != zip.Store && e.Method != zip.Deflate { - return rejected("压缩包里 %s 用了不支持的压缩方式 %d", e.Name, e.Method) - } - m := newMeter(f.o.Progress, "extracting", c.FetchName, max(int64(e.UncompressedSize64), 1), 0) - n, err := f.extractFile(ctx, e, dest, make([]byte, 1<<20), 0, m) - if err != nil { - return err + member = e + if !f.darwin() { + break } - if n != int64(e.UncompressedSize64) { - return rejected("解压出的 %s 大小不对", e.Name) + } + if member == nil { + return rejected("压缩包里没有 %s", name) + } + if f.darwin() { + if member.Name != name || !member.Mode().IsRegular() || + member.UncompressedSize64 != uint64(playstore.CurrentProfile().ImageMemberSize) { + return rejected("压缩包里的 %s 类型、路径或大小与组件清单不符", name) } - m.finish(n) - return nil } - return rejected("压缩包里没有 %s", name) + if member.Flags&1 != 0 { + return rejected("压缩包里的 %s 是加密的", name) + } + if member.Method != zip.Store && member.Method != zip.Deflate { + return rejected("压缩包里 %s 用了不支持的压缩方式 %d", name, member.Method) + } + m := newMeter(f.o.Progress, "extracting", c.FetchName, max(int64(member.UncompressedSize64), 1), 0) + n, err := f.extractFile(ctx, member, dest, make([]byte, 1<<20), 0, m) + if err != nil { + return err + } + if n != int64(member.UncompressedSize64) { + return rejected("解压出的 %s 大小不对", name) + } + m.finish(n) + return nil } // place moves an unpacked, checked component directory (extracted) into sdk\ (the end of the script's diff --git a/internal/components/platform_darwin.go b/internal/components/platform_darwin.go index e75d28f..55b58b1 100644 --- a/internal/components/platform_darwin.go +++ b/internal/components/platform_darwin.go @@ -26,9 +26,18 @@ func (f *fetcher) platformPaths() error { filepath.Join(f.r.Dir, "downloads", licenseFile), filepath.Join(f.r.Dir, "downloads", "android-sdk-arm-dbt-license.txt"), } - for _, c := range componentCatalogue() { + specs := componentCatalogue() + if f.o.PlayStore { + specs = append(append([]root.SDKComponent(nil), specs...), playStoreCatalogue()) + } + for _, c := range specs { paths = append(paths, c.Dir(f.sdk)) files = append(files, f.zipPath(c), f.zipPath(c)+".part") + if c.ID == playStoreCatalogue().ID { + staging := filepath.Join(f.stagingRoot(), c.ID) + paths = append(paths, staging, filepath.Join(staging, root.PlayStoreDir)) + files = append(files, filepath.Join(staging, "source.zip"), filepath.Join(staging, "system.img")) + } } for _, p := range paths { rel, err := filepath.Rel(f.r.Dir, p) diff --git a/internal/components/playstore.go b/internal/components/playstore.go index b052513..d5d5b4e 100644 --- a/internal/components/playstore.go +++ b/internal/components/playstore.go @@ -42,7 +42,7 @@ const ( // playStoreFiles is the table of the five files (a variable for the tests, with root.PlayStoreComponent's pins // swapped in through playStoreCatalogue to match). -var playStoreFiles = playstore.Files +var playStoreFiles = playstore.CurrentProfile().Files // PlayStoreFile is one of the five files in a PlayStoreStatus. type PlayStoreFile struct { @@ -56,18 +56,23 @@ type PlayStoreFile struct { // instance buttons show. Cheap: sizes and source.properties only; the SHA-256 of each file is checked where the files // are put in (fetch, import) and used (playstore install). type PlayStoreStatus struct { - Dir string `json:"dir"` // \playstore - Ready bool `json:"ready"` // all five are there and source.properties has the revision - Source string `json:"source"` // download, import, or "" (none, or put there some other way) - Version string `json:"version"` // Pkg.Revision, "" when none - Files []PlayStoreFile `json:"files"` + Dir string `json:"dir"` // \playstore + Ready bool `json:"ready"` // all five are there and source.properties has the revision + Source string `json:"source"` // download, import, or "" (none, or put there some other way) + Version string `json:"version"` // Pkg.Revision, "" when none + Files []PlayStoreFile `json:"files"` + Archive string `json:"archive"` // the matching Google Play image zip + DownloadBytes int64 `json:"download_bytes"` // size of that archive + InstallSupported bool `json:"install_supported"` // preparing the files does not enable instance operations } // PlayStoreState reads r's Google Play Store files (in the SDK the instances run on). func PlayStoreState(r *root.Root) PlayStoreStatus { c := playStoreCatalogue() dir := c.Dir(r.RuntimeSDK()) - st := PlayStoreStatus{Dir: dir, Files: []PlayStoreFile{}} + profile := playstore.CurrentProfile() + st := PlayStoreStatus{Dir: dir, Files: []PlayStoreFile{}, Archive: filepath.Base(profile.ImageURL), + DownloadBytes: profile.ImageSize, InstallSupported: root.CurrentPlatform().Windows()} all := true for _, f := range playStoreFiles { fi, err := os.Stat(filepath.Join(dir, f.Name)) @@ -81,6 +86,9 @@ func PlayStoreState(r *root.Root) PlayStoreStatus { st.Source = values[playStoreSourceKey] } st.Ready = all && strings.EqualFold(st.Version, c.Version) + if len(c.RequiredProperties) != 0 { + st.Ready = all && c.MetadataMatches(dir) + } return st } @@ -160,7 +168,7 @@ func importRejected(reason, format string, args ...any) *errs.Error { } // ImportPlayStore puts the Google Play Store's five files into r's sdk\playstore from files the user has: Google's -// Play image zip (x86_64-34_r14.zip under any name, checked against the size and SHA-1 of Google's repository, then +// Play image zip for this host (under any name, checked against the size and SHA-1 of Google's repository, then // read like a download), or the five files themselves under any names (each checked against its pinned size and // SHA-256 while it is copied). A file whose size is neither is refused before anything is read; one whose hash does // not match after; nothing changes then. Files not given are taken from sdk\playstore when they are there and match, @@ -168,31 +176,41 @@ func importRejected(reason, format string, args ...any) *errs.Error { // are still checked and nothing changes (Skipped). The user's files are only read. It holds the fetch's lock and // writes logs\fetch-components.log like a fetch; no license is asked (nothing is downloaded). // -// Error codes: Unsupported (not Windows), Usage (no file), NotFound, NotReady (not a ModAVD root, or sdkRoot set), +// Error codes: Unsupported (no native catalogue), Usage (no file), NotFound, NotReady (not a ModAVD root, or sdkRoot set), // Busy (a fetch or an import runs), PrecheckFailed (space), ComponentsRejected (errs.Reason: ImportReason*), // Canceled, Internal. func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Options) (res *PlayStoreImportResult, err error) { - if fetchGOOS != "windows" { - return nil, errs.New(errs.Unsupported, "这个系统还没有组件清单").WithAdvice("目前只有 Windows 能补全组件") + if fetchGOOS != "windows" && !(fetchGOOS == "darwin" && nativeDarwinComponents) { + return nil, errs.New(errs.Unsupported, "这个系统还没有组件清单").WithAdvice("目前支持 Windows 和 Apple Silicon macOS") } if r == nil { return nil, errs.New(errs.NotFound, "找不到魔改 AVD 根目录") } if len(files) == 0 { return nil, errs.New(errs.Usage, "没有给要导入的文件"). - WithAdvice("给 Google Play 版镜像的压缩包 x86_64-34_r14.zip,或者商店的五个文件:" + strings.Join(playStoreNames(), "、")) + WithAdvice("给 Google Play 版镜像的压缩包 " + filepath.Base(playstore.CurrentProfile().ImageURL) + ",或者商店的五个文件:" + strings.Join(playStoreNames(), "、")) } if o.Log == nil { o.Log = logx.Nop() } + o.PlayStore = true f := &fetcher{r: r, o: o, sdk: r.SDK()} - if _, err := os.Stat(filepath.Join(f.sdk, "emulator", "emulator.exe")); err != nil { - return nil, errs.Newf(errs.NotReady, `这里没有 sdk\emulator\emulator.exe,不像是魔改 AVD 的文件夹:%s`, r.Dir) + emulator := filepath.Join("sdk", "emulator", root.CurrentPlatform().Emulator) + if _, err := os.Stat(filepath.Join(f.sdk, "emulator", root.CurrentPlatform().Emulator)); err != nil { + return nil, errs.Newf(errs.NotReady, "这里没有 %s,不像是魔改 AVD 的文件夹:%s", emulator, r.Dir) } if r.LocalSDKRoot() != "" { return nil, errs.New(errs.NotReady, "这个根目录在 mas-avd.json 里设了 sdkRoot(开发用的本地 SDK),不导入组件"). WithAdvice(`实例用的是那份 SDK;要给这个根目录的 sdk\ 导入,先去掉 sdkRoot`) } + if f.darwin() { + if err := f.platformPaths(); err != nil { + return nil, err + } + if err := f.checkImportSources(files); err != nil { + return nil, err + } + } f.openLog() defer f.closeLog() lock, err := f.lock() @@ -206,12 +224,22 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op f.note("失败:" + f.where) } f.note("原因:" + errs.As(err).Error()) - f.clearStaging() + if !f.darwin() || f.platformPaths() == nil { + f.clearStaging() + } } f.note(fmt.Sprintf("退出码 %d", errs.ExitCode(err))) }() f.say("魔改 AVD:用自己的文件补全 Google Play 商店") f.say("文件夹:" + r.Dir) + if f.darwin() { + if err := f.platformPaths(); err != nil { + return nil, err + } + if err := f.platformIdle(); err != nil { + return nil, err + } + } f.clearStaging() c := playStoreCatalogue() @@ -236,6 +264,18 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op if zipItem != nil { res.From = "zip" need = 500<<20 + c.ExtractedBytes + if f.darwin() { + // Hash and extract the same private copy: the user's path may be + // replaced after the import has verified it. + need += c.Size + } + } else if f.darwin() { + // Missing files are copied from the old component too. It remains in + // place until the complete replacement is ready. + need = 500 << 20 + for _, pf := range playStoreFiles { + need += pf.Size + } } if err := f.importSpace(need); err != nil { return nil, err @@ -246,6 +286,13 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op return nil, err } defer f.removeStaging(staging) + zipPath := "" + if zipItem != nil { + zipPath = zipItem.Path + if f.darwin() { + zipPath = filepath.Join(staging, "source.zip") + } + } // every given file first: a wrong one refuses the import before the 3 GB of a zip are unpacked have := make([]bool, len(playStoreFiles)) for k := range items { @@ -255,14 +302,17 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op } if it.zip { f.where = "校验 " + it.name() - f.say("校验 " + it.name() + "(SHA-1)…") - sum, err := f.readHashed(ctx, it.Path, sha1.New(), nil, it.name()) - if err != nil { - return nil, err + algorithm := "SHA-1" + if c.SHA256 != "" { + algorithm += "、SHA-256" } - if sum != c.SHA1 { - return nil, importRejected(ImportReasonMismatch, "%s 的 SHA-1 是 %s,应为 %s:不是 Google 仓库里那份 Google Play 版镜像(%s)", - it.name(), sum, c.SHA1, filepath.Base(playstore.ImageURL)) + f.say("校验 " + it.name() + "(" + algorithm + ")…") + copyTo := "" + if f.darwin() && it == zipItem { + copyTo = zipPath + } + if err := f.verifyImportArchive(ctx, *it, c, copyTo); err != nil { + return nil, err } } else { want := playStoreFiles[it.slot] @@ -271,16 +321,16 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op var sum string if have[it.slot] { // the same file given twice: checked, the first copy stays - sum, err = f.readHashed(ctx, it.Path, sha256.New(), nil, it.name()) + sum, err = f.readHashed(ctx, it.Path, sha256.New(), nil, it.name(), want.Size) } else { - sum, err = f.copyHashed(ctx, it.Path, filepath.Join(out, want.Name), it.name()) + sum, err = f.copyHashed(ctx, it.Path, filepath.Join(out, want.Name), it.name(), want.Size) } if err != nil { return nil, err } if sum != want.SHA256 { - return nil, importRejected(ImportReasonMismatch, "%s 和 Google Play 版镜像 r14 里的 %s 大小一样,SHA-256 却对不上(是 %s,应为 %s)", - it.name(), want.Name, sum, want.SHA256) + return nil, importRejected(ImportReasonMismatch, "%s 和 Google Play 版镜像 r%s 里的 %s 大小一样,SHA-256 却对不上(是 %s,应为 %s)", + it.name(), c.Version, want.Name, sum, want.SHA256) } have[it.slot] = true } @@ -295,7 +345,12 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op } if zipItem != nil { emptyDir(out) - if err := f.playStoreFromZip(ctx, c, zipItem.Path, staging, out); err != nil { + if f.darwin() { + if err := f.importSpace(500<<20 + c.ExtractedBytes); err != nil { + return nil, err + } + } + if err := f.playStoreFromZip(ctx, c, zipPath, staging, out); err != nil { return nil, err } } else { @@ -307,7 +362,7 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op // one already in place and right stays (copied: the old directory is only set aside when the new one is whole) f.where = `检查 sdk\` + c.Target + `\` + pf.Name if fi, err := os.Stat(filepath.Join(target, pf.Name)); err == nil && fi.Mode().IsRegular() && fi.Size() == pf.Size { - sum, err := f.copyHashed(ctx, filepath.Join(target, pf.Name), filepath.Join(out, pf.Name), pf.Name) + sum, err := f.copyHashed(ctx, filepath.Join(target, pf.Name), filepath.Join(out, pf.Name), pf.Name, pf.Size) if errs.CodeOf(err) == errs.Canceled { return nil, err } @@ -321,10 +376,10 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op } if len(missing) > 0 { return nil, importRejected(ImportReasonIncomplete, "还缺 %s,什么也没有改", strings.Join(missing, "、")). - WithAdvice("把五个文件一起给出,或者给 Google Play 版镜像的压缩包 " + filepath.Base(playstore.ImageURL)) + WithAdvice("把五个文件一起给出,或者给 Google Play 版镜像的压缩包 " + filepath.Base(c.URL)) } } - aside, err := f.finishPlayStore(c, out, PlayStoreFromImport) + aside, err := f.finishPlayStore(ctx, c, out, PlayStoreFromImport) res.SetAside = setAsideRel(f.sdk, aside) if err != nil { return nil, err @@ -334,6 +389,87 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op return res, nil } +// A source inside staging would be deleted by clearStaging before it is read. +// Compare real ancestor identities to cover symlinks and case aliases too. +// This must run before any cleanup defer is registered. +func (f *fetcher) checkImportSources(files []ImportFile) error { + staging, err := os.Stat(f.stagingRoot()) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return errs.Wrap(errs.Internal, err, "读不了组件暂存目录") + } + for _, in := range files { + current, err := filepath.EvalSymlinks(in.Path) + if err != nil { + return errs.Newf(errs.NotFound, "找不到 %s", in.name()) + } + current, err = filepath.Abs(current) + if err != nil { + return errs.Wrap(errs.Internal, err, "解析不了导入文件路径") + } + for { + info, err := os.Stat(current) + if err != nil { + return errs.Wrap(errs.Internal, err, "读不了导入文件的目录") + } + if os.SameFile(info, staging) { + return importRejected(ImportReasonUnknown, "%s 位于组件暂存目录内,不能作为导入源文件", in.name()). + WithAdvice("请先把源文件移到 sdk/.staging 之外,再重新导入;原文件未改动") + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + } + return nil +} + +// verifyImportArchive checks the bytes read, and on macOS retains that checked +// copy in private staging so extraction never reopens the user's mutable path. +func (f *fetcher) verifyImportArchive(ctx context.Context, it importItem, c root.SDKComponent, copyTo string) error { + var writer io.Writer + var strong hash.Hash + if c.SHA256 != "" { + strong = sha256.New() + writer = strong + } + var out *os.File + if copyTo != "" { + var err error + out, err = os.OpenFile(copyTo, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return errs.Wrap(errs.Internal, err, "写不了导入镜像的暂存副本") + } + writer = out + if strong != nil { + writer = io.MultiWriter(out, strong) + } + } + sum, err := f.readHashed(ctx, it.Path, sha1.New(), writer, it.name(), c.Size) + if out != nil { + if cerr := out.Close(); err == nil && cerr != nil { + err = errs.Wrap(errs.Internal, cerr, "写不了导入镜像的暂存副本") + } + } + if err != nil { + return err + } + if sum != c.SHA1 { + return importRejected(ImportReasonMismatch, "%s 的 SHA-1 是 %s,应为 %s:不是 Google 仓库里那份 Google Play 版镜像(%s)", + it.name(), sum, c.SHA1, filepath.Base(c.URL)) + } + if strong != nil { + if got := hex.EncodeToString(strong.Sum(nil)); !strings.EqualFold(got, c.SHA256) { + return importRejected(ImportReasonMismatch, "%s 的 SHA-256 是 %s,应为 %s", it.name(), got, c.SHA256) + } + } + return nil +} + // emptyDir empties dir (out of a zip import: files given beside the zip were copied there and are not used). func emptyDir(dir string) { entries, _ := os.ReadDir(dir) @@ -368,18 +504,23 @@ func classifyImport(files []ImportFile, c root.SDKComponent) ([]importItem, erro if !it.zip && it.slot < 0 { return nil, importRejected(ImportReasonUnknown, "%s(%d 字节)不是 Google Play 版镜像的压缩包 %s,也不是商店的五个文件(%s)之一", - in.name(), fi.Size(), filepath.Base(playstore.ImageURL), strings.Join(playStoreNames(), "、")) + in.name(), fi.Size(), filepath.Base(c.URL), strings.Join(playStoreNames(), "、")) } items = append(items, it) } return items, nil } -// importSpace: need bytes must fit on the root's drive (TestFreeSpaceMB as in a fetch). +// importSpace: imports are staged on the SDK's volume on macOS, which can differ +// from the root's volume. TestFreeSpaceMB is treated as in a fetch. func (f *fetcher) importSpace(need int64) error { drive := filepath.VolumeName(f.r.Dir) + `\` + spacePath := f.r.Dir + if f.darwin() { + drive, spacePath = f.sdk, f.sdk + } f.where = "查 " + drive + " 的剩余空间" - freeBytes, err := diskFree(f.r.Dir) + freeBytes, err := diskFree(spacePath) if err != nil { return errs.Wrap(errs.Internal, err, fmt.Sprintf("查不了 %s 的剩余空间:%s", drive, errs.As(err).Message)) } @@ -393,8 +534,9 @@ func (f *fetcher) importSpace(need int64) error { return nil } -// readHashed is the hex hash h of the file at p, read with progress (verifying) and a stop at cancel. -func (f *fetcher) readHashed(ctx context.Context, p string, h hash.Hash, w io.Writer, name string) (string, error) { +// readHashed is the hex hash h of the file at p, read with progress (verifying) +// and a stop at cancel. Native imports also bound the copy to its pinned size. +func (f *fetcher) readHashed(ctx context.Context, p string, h hash.Hash, w io.Writer, name string, expected int64) (string, error) { in, err := os.Open(p) if err != nil { return "", errs.Wrap(errs.Internal, err, "读不了 "+name+":"+err.Error()) @@ -404,6 +546,9 @@ func (f *fetcher) readHashed(ctx context.Context, p string, h hash.Hash, w io.Wr if err != nil { return "", errs.Wrap(errs.Internal, err, "读不了 "+name+":"+err.Error()) } + if f.darwin() && (!st.Mode().IsRegular() || st.Size() != expected) { + return "", importRejected(ImportReasonMismatch, "%s 在导入时已变化,类型或大小与组件清单不符", name) + } m := newMeter(f.o.Progress, "verifying", name, max(st.Size(), 1), 0) dst := io.Writer(h) if w != nil { @@ -417,6 +562,9 @@ func (f *fetcher) readHashed(ctx context.Context, p string, h hash.Hash, w io.Wr } n, rerr := in.Read(buf) if n > 0 { + if f.darwin() && done+int64(n) > expected { + return "", importRejected(ImportReasonMismatch, "%s 在导入时超过组件清单的大小", name) + } if _, err := dst.Write(buf[:n]); err != nil { return "", errs.Wrap(errs.Internal, err, "写不了 "+name+" 的副本:"+err.Error()) } @@ -430,18 +578,21 @@ func (f *fetcher) readHashed(ctx context.Context, p string, h hash.Hash, w io.Wr return "", errs.Wrap(errs.Internal, rerr, "读不了 "+name+":"+rerr.Error()) } } + if f.darwin() && done != expected { + return "", importRejected(ImportReasonMismatch, "%s 在导入时已变化,大小与组件清单不符", name) + } m.finish(done) return hex.EncodeToString(h.Sum(nil)), nil } // copyHashed copies src to dst (new) and returns the SHA-256 of what was written: the copy is what gets checked, so a // file that changes meanwhile cannot slip in. -func (f *fetcher) copyHashed(ctx context.Context, src, dst, name string) (string, error) { +func (f *fetcher) copyHashed(ctx context.Context, src, dst, name string, expected int64) (string, error) { out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return "", errs.Wrap(errs.Internal, err, "写不了 "+dst+":"+err.Error()) } - sum, err := f.readHashed(ctx, src, sha256.New(), out, name) + sum, err := f.readHashed(ctx, src, sha256.New(), out, name, expected) if cerr := out.Close(); err == nil && cerr != nil { err = errs.Wrap(errs.Internal, cerr, "写不了 "+dst+":"+cerr.Error()) } diff --git a/internal/playstore/erofs.go b/internal/playstore/erofs.go new file mode 100644 index 0000000..cca51ad --- /dev/null +++ b/internal/playstore/erofs.go @@ -0,0 +1,548 @@ +package playstore + +// Read the ARM64 Google Play image's EROFS flat files and compact LZ4 files. +// Other compression algorithms, chunk files, fragments and external devices are unsupported. + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "strings" +) + +const erofsMagic = 0xE0F5E1E2 + +type erofs struct { + r io.ReaderAt + size int64 + blockSize int64 + metaOff int64 + root uint64 + zeroPadding bool + maxPcluster int64 + bigPcluster bool +} + +type erofsInode struct { + mode uint16 + size int64 + dataOff int64 + inlineOff int64 // -1 for plain files; otherwise the tail's metadata offset + layout uint16 + metaEnd int64 +} + +func openEROFS(p *partition) (*erofs, error) { + sb := make([]byte, 128) + if _, err := p.ReadAt(sb, 1024); err != nil { + return nil, fmt.Errorf("读不了 EROFS 超级块:%w", err) + } + if binary.LittleEndian.Uint32(sb) != erofsMagic { + return nil, errors.New("不是 EROFS 文件系统") + } + // New addressing, external devices, chunks, fragments and metaboxes + // change the addressing rules and require another reader. + if binary.LittleEndian.Uint32(sb[80:])&^uint32(3) != 0 || + binary.LittleEndian.Uint16(sb[86:]) != 0 || sb[90] != 0 { + return nil, errors.New("不支持 EROFS 的文件系统特性") + } + if sb[12] < 10 || sb[12] > 16 { + return nil, errors.New("EROFS 块大小不对") + } + fs := &erofs{r: p, blockSize: 1 << sb[12], root: uint64(binary.LittleEndian.Uint16(sb[14:]))} + fs.size = int64(binary.LittleEndian.Uint32(sb[36:])) * fs.blockSize + fs.metaOff = int64(binary.LittleEndian.Uint32(sb[40:])) * fs.blockSize + if fs.size > p.size || fs.size < fs.blockSize || fs.metaOff >= fs.size || + 128+int64(sb[13])*16 > fs.blockSize-1024 { + return nil, errors.New("EROFS 超级块的范围不对") + } + if binary.LittleEndian.Uint32(sb[8:])&1 != 0 { + b, err := fs.read(1024, fs.blockSize-1024) + if err != nil { + return nil, err + } + want := binary.LittleEndian.Uint32(b[4:]) + clear(b[4:8]) + if ^crc32.Checksum(b, crc32.MakeTable(crc32.Castagnoli)) != want { + return nil, errors.New("EROFS 超级块的校验和不对") + } + } + fs.zeroPadding = binary.LittleEndian.Uint32(sb[80:])&1 != 0 + fs.bigPcluster = binary.LittleEndian.Uint32(sb[80:])&2 != 0 + fs.maxPcluster = 1 + if fs.bigPcluster { + if binary.LittleEndian.Uint16(sb[84:]) != 1 { + return nil, errors.New("EROFS 只支持 LZ4 压缩") + } + at := int64(1024 + 128 + int(sb[13])*16) + h, err := fs.read(at, 2) + if err != nil { + return nil, err + } + n := int64(binary.LittleEndian.Uint16(h)) + if n < 14 { + return nil, errors.New("EROFS LZ4 配置太短") + } + cfg, err := fs.read(at+2, n) + if err != nil { + return nil, err + } + fs.maxPcluster = max(1, int64(binary.LittleEndian.Uint16(cfg[2:]))) + if fs.maxPcluster > (1<<20)/fs.blockSize { + return nil, errors.New("EROFS 压缩块太大") + } + } + return fs, nil +} + +func (fs *erofs) read(off, size int64) ([]byte, error) { + if off < 0 || size < 0 || off > fs.size || size > fs.size-off { + return nil, errors.New("读的范围超出 EROFS 文件系统") + } + b := make([]byte, size) + if _, err := fs.r.ReadAt(b, off); err != nil { + return nil, err + } + return b, nil +} + +func (fs *erofs) inode(nid uint64) (*erofsInode, error) { + if nid > uint64((fs.size-fs.metaOff-32)/32) { + return nil, fmt.Errorf("EROFS nid %d 超出文件系统", nid) + } + off := fs.metaOff + int64(nid)*32 + b, err := fs.read(off, 32) + if err != nil { + return nil, err + } + format := binary.LittleEndian.Uint16(b) + layout := (format >> 1) & 7 + if format&^uint16(15) != 0 || (layout != 0 && layout != 2 && layout != 3) { + return nil, fmt.Errorf("不支持 EROFS inode 格式 %#x", format) + } + inodeSize := int64(32) + if format&1 != 0 { + inodeSize = 64 + b, err = fs.read(off, inodeSize) + if err != nil { + return nil, err + } + } + size := uint64(binary.LittleEndian.Uint32(b[8:])) + if inodeSize == 64 { + size = binary.LittleEndian.Uint64(b[8:]) + } + if size > 1<<63-1 || layout != 3 && size > uint64(fs.size) { + return nil, errors.New("EROFS 文件大小不对") + } + xattrSize := int64(0) + if n := int64(binary.LittleEndian.Uint16(b[2:])); n != 0 { + xattrSize = 12 + (n-1)*4 + } + metaEnd := off + inodeSize + xattrSize + if metaEnd > fs.size { + return nil, errors.New("EROFS inode 元数据超出文件系统") + } + in := &erofsInode{ + mode: binary.LittleEndian.Uint16(b[4:]), + size: int64(size), + dataOff: int64(binary.LittleEndian.Uint32(b[16:])) * fs.blockSize, + inlineOff: -1, + layout: layout, + metaEnd: metaEnd, + } + if layout == 3 { + return in, nil + } + dataSize := in.size + if layout == 2 { + in.inlineOff = metaEnd + tail := in.size % fs.blockSize + if tail > fs.blockSize-metaEnd%fs.blockSize || tail > fs.size-metaEnd { + return nil, errors.New("EROFS 内联数据超出元数据块") + } + dataSize -= tail + } + if dataSize > 0 && (in.dataOff > fs.size || dataSize > fs.size-in.dataOff) { + return nil, errors.New("EROFS 文件数据超出文件系统") + } + return in, nil +} + +func (fs *erofs) copyData(in *erofsInode, w io.Writer) (int64, error) { + if in.layout == 3 { + return fs.copyCompressed(in, w) + } + dataSize := in.size + if in.inlineOff >= 0 { + dataSize -= in.size % fs.blockSize + } + n, err := io.Copy(w, io.NewSectionReader(fs.r, in.dataOff, dataSize)) + if err != nil || n != dataSize { + if err == nil { + err = io.ErrUnexpectedEOF + } + return n, err + } + if dataSize == in.size { + return n, nil + } + m, err := io.Copy(w, io.NewSectionReader(fs.r, in.inlineOff, in.size-dataSize)) + if err == nil && m != in.size-dataSize { + err = io.ErrUnexpectedEOF + } + return n + m, err +} + +func (fs *erofs) copyFile(path string, size int64, w io.Writer) (int64, error) { + nid, err := fs.lookup(path) + if err != nil { + return 0, err + } + in, err := fs.inode(nid) + if err != nil { + return 0, err + } + if in.mode&0xF000 != 0x8000 { + return 0, errors.New("不是普通文件") + } + if in.size != size { + return 0, fmt.Errorf("大小是 %d,应为 %d", in.size, size) + } + return fs.copyData(in, w) +} + +// lookup walks directories only; symlinks are never followed. +func (fs *erofs) lookup(path string) (uint64, error) { + nid := fs.root + for _, name := range strings.Split(strings.Trim(path, "/"), "/") { + if name == "" || name == "." || name == ".." || len(name) > 255 { + return 0, fmt.Errorf("路径不对:%s", path) + } + in, err := fs.inode(nid) + if err != nil { + return 0, err + } + if in.mode&0xF000 != 0x4000 { + return 0, fmt.Errorf("%s 不在目录里", name) + } + if in.size > maxDirBytes { + return 0, errors.New("EROFS 目录太大") + } + var b bytes.Buffer + if _, err := fs.copyData(in, &b); err != nil { + return 0, err + } + next, err := fs.findEntry(b.Bytes(), name) + if err != nil { + return 0, err + } + nid = next + } + return nid, nil +} + +func (fs *erofs) findEntry(dir []byte, name string) (uint64, error) { + for off := 0; off < len(dir); off += int(fs.blockSize) { + b := dir[off:min(off+int(fs.blockSize), len(dir))] + if len(b) < 12 { + return 0, errors.New("EROFS 目录块太短") + } + end := int(binary.LittleEndian.Uint16(b[8:])) + if end < 12 || end%12 != 0 || end > len(b) { + return 0, errors.New("EROFS 目录项范围不对") + } + for pos := 0; pos < end; pos += 12 { + start := int(binary.LittleEndian.Uint16(b[pos+8:])) + next := len(b) + if pos+12 < end { + next = int(binary.LittleEndian.Uint16(b[pos+20:])) + } + if start < end || next <= start || next > len(b) { + return 0, errors.New("EROFS 目录项名称范围不对") + } + n := b[start:next] + if pos+12 == end { + if zero := bytes.IndexByte(n, 0); zero >= 0 { + n = n[:zero] + } + } + if len(n) == 0 || len(n) > 255 || bytes.IndexByte(n, 0) >= 0 || bytes.IndexByte(n, '/') >= 0 { + return 0, errors.New("EROFS 目录项名称不对") + } + if string(n) == name { + return binary.LittleEndian.Uint64(b[pos:]), nil + } + } + } + return 0, fmt.Errorf("没有 %s", name) +} + +// Each compact index pack has either two 16-bit entries or sixteen 14-bit +// entries followed by a physical-block base. Non-head entries encode distances +// or, immediately after a head, the number of physical blocks in that cluster. +type erofsIndex struct { + typ uint32 + lo uint32 + block int64 +} + +type erofsCluster struct { + logical int64 + block int64 + blocks int64 + plain bool +} + +func (fs *erofs) compressedClusters(in *erofsInode) ([]erofsCluster, error) { + at := (in.metaEnd + 7) &^ 7 + h, err := fs.read(at, 8) + if err != nil { + return nil, err + } + advise := binary.LittleEndian.Uint16(h[4:]) + // The pinned image uses 4 KiB logical clusters, LZ4 for both head types, + // and optional 2-byte indexes/big clusters, without compressed tail packing. + if fs.blockSize != 4096 || !fs.zeroPadding || binary.LittleEndian.Uint32(h) != 0 || + h[6] != 0 || h[7] != 0 || advise&^uint16(7) != 0 || (advise>>1)&3 != 0 && (advise>>1)&3 != 3 { + return nil, errors.New("不支持 EROFS 压缩 inode 的布局或算法") + } + big := advise&2 != 0 + if big && !fs.bigPcluster { + return nil, errors.New("EROFS 压缩块缺少文件系统特性") + } + base := at + 8 + total := in.size / fs.blockSize + if in.size%fs.blockSize != 0 { + total++ + } + initial := ((32 - base%32) % 32) / 4 + compact := int64(0) + if advise&1 != 0 && total > initial { + compact = ((total - initial) / 16) * 16 + } + load := func(lcn int64) (erofsIndex, error) { + pos, width, count := base+lcn*4, int64(4), int64(2) + if lcn >= initial { + if lcn < initial+compact { + pos, width, count = base+initial*4+(lcn-initial)*2, 2, 16 + } else { + pos = base + initial*4 + compact*2 + (lcn-initial-compact)*4 + } + } + packSize := width * count + packOff := pos - pos%packSize + b, err := fs.read(packOff, packSize) + if err != nil { + return erofsIndex{}, err + } + i := (pos - packOff) / width + bits := (packSize - 4) * 8 / count + decode := func(j int64) (typ, lo uint32) { + bit := j * bits + v := binary.LittleEndian.Uint32(b[bit/8:]) >> (bit % 8) + return (v >> 12) & 3, v & 4095 + } + typ, lo := decode(i) + if typ == 2 { + // The pack's final non-head stores a forward distance; its + // backward distance is one plus that of its predecessor. + if i == count-1 && lo&2048 == 0 { + pt, pl := decode(i - 1) + lo = 1 + if pt == 2 { + lo = pl + 1 + if pl&2048 != 0 { + lo = 2 + } + } + } + return erofsIndex{typ: typ, lo: lo}, nil + } + block := int64(binary.LittleEndian.Uint32(b[packSize-4:])) + if !big { + block++ // legacy packs store the block preceding their first head + } + // Count physical blocks represented by earlier heads in this pack. + for j := i - 1; j >= 0; j-- { + pt, pl := decode(j) + if pt != 2 { + block++ + continue + } + if big { + if pl&2048 != 0 { + block += int64(pl &^ 2048) + j-- // that count also accounts for its preceding head + } else { + if pl <= 1 { + return erofsIndex{}, errors.New("EROFS 压缩块距离不对") + } + j -= int64(pl) - 2 + } + } else { + j -= int64(pl) + if j >= 0 { + block++ + } + } + } + return erofsIndex{typ, lo, block}, nil + } + var clusters []erofsCluster + var headLCN int64 + for lcn := int64(0); lcn < total; lcn++ { + x, err := load(lcn) + if err != nil { + return nil, err + } + if x.typ == 2 { + if len(clusters) == 0 { + return nil, errors.New("EROFS 压缩索引缺少起始块") + } + distance := lcn - headLCN + if x.lo&2048 != 0 { + blocks := int64(x.lo &^ 2048) + if !big || distance != 1 || blocks == 0 || blocks > fs.maxPcluster { + return nil, errors.New("EROFS 压缩块数量不对") + } + clusters[len(clusters)-1].blocks = blocks + } else if int64(x.lo) != distance || big && distance == 1 { + return nil, errors.New("EROFS 压缩索引距离不对") + } + continue + } + logical := lcn*fs.blockSize + int64(x.lo) + // A final head exactly at EOF terminates the preceding cluster. + if logical == in.size && lcn == total-1 && len(clusters) > 0 { + break + } + if logical >= in.size || len(clusters) == 0 && logical != 0 || + len(clusters) != 0 && logical <= clusters[len(clusters)-1].logical { + return nil, fmt.Errorf("EROFS 压缩索引的文件偏移不对(簇 %d,偏移 %d,大小 %d)", lcn, logical, in.size) + } + clusters = append(clusters, erofsCluster{logical, x.block, 1, x.typ == 0}) + headLCN = lcn + } + return clusters, nil +} + +func (fs *erofs) copyCompressed(in *erofsInode, w io.Writer) (int64, error) { + clusters, err := fs.compressedClusters(in) + if err != nil { + return 0, err + } + var done int64 + for i, c := range clusters { + end := in.size + if i+1 < len(clusters) { + end = clusters[i+1].logical + } + length := end - c.logical + if c.logical != done || length <= 0 || length > 12<<20 || c.blocks > fs.maxPcluster { + return done, errors.New("EROFS 压缩簇范围不对") + } + b, err := fs.read(c.block*fs.blockSize, c.blocks*fs.blockSize) + if err != nil { + return done, err + } + if c.plain { + if length > fs.blockSize { + return done, errors.New("EROFS 普通压缩簇太大") + } + b = b[:length] + } else { + padding := 0 + for padding < min(len(b), int(fs.blockSize)) && b[padding] == 0 { + padding++ + } + if padding == int(fs.blockSize) { + return done, errors.New("EROFS 压缩簇没有数据") + } + b, err = decodeEROFSBlock(b[padding:], int(length)) + if err != nil { + return done, err + } + } + n, err := w.Write(b) + done += int64(n) + if err != nil { + return done, err + } + if n != len(b) { + return done, io.ErrShortWrite + } + } + if done != in.size { + return done, io.ErrUnexpectedEOF + } + return done, nil +} + +// decodeEROFSBlock decodes a raw LZ4 block into exactly size bytes, with bounds +// checked before every read, copy and overlapping match. No frame/dictionary is used. +func decodeEROFSBlock(src []byte, size int) ([]byte, error) { + if size <= 0 || size > 12<<20 || len(src) > 1<<20 { + return nil, errors.New("LZ4 块大小不对") + } + dst := make([]byte, size) + ip, op := 0, 0 + length := func(n int) (int, error) { + if n == 15 { + for { + if ip >= len(src) { + return 0, io.ErrUnexpectedEOF + } + b := int(src[ip]) + ip++ + if n > size-b { + return 0, errors.New("LZ4 长度超出输出范围") + } + n += b + if b != 255 { + break + } + } + } + return n, nil + } + for ip < len(src) { + token := src[ip] + ip++ + n, err := length(int(token >> 4)) + if err != nil { + return nil, err + } + if n > len(src)-ip || n > size-op { + return nil, errors.New("LZ4 原文长度不对") + } + copy(dst[op:op+n], src[ip:ip+n]) + ip, op = ip+n, op+n + if ip == len(src) { + if op != size { + return nil, errors.New("LZ4 输出大小不对") + } + return dst, nil + } + if len(src)-ip < 2 { + return nil, io.ErrUnexpectedEOF + } + distance := int(binary.LittleEndian.Uint16(src[ip:])) + ip += 2 + n, err = length(int(token & 15)) + if err != nil { + return nil, err + } + n += 4 + if distance == 0 || distance > op || n > size-op { + return nil, errors.New("LZ4 匹配范围不对") + } + for end := op + n; op < end; op++ { + dst[op] = dst[op-distance] + } + } + return nil, io.ErrUnexpectedEOF +} diff --git a/internal/playstore/files.go b/internal/playstore/files.go index 5568a53..6c3a429 100644 --- a/internal/playstore/files.go +++ b/internal/playstore/files.go @@ -1,4 +1,5 @@ -// Package playstore is the Google Play Store a ModAVD instance can have (ModAVD/ModAVD#125). The instances run +// Package playstore prepares pinned Google Play files and supports Windows instance installation (ModAVD/ModAVD#125). +// Host-specific component tables live in profile.go; macOS currently only prepares API35 ARM64 files. Windows instances run // Google's "Google APIs" image, which has Google Play services but no store; the store comes from Google's "Google // Play" image of the same release (system-images;android-34;google_apis_playstore;x86_64 r14), downloaded on the // user's computer like the other Google components and never shipped by ModAVD. @@ -10,8 +11,8 @@ // store's fs-verity certificate. They go into the instance's writable system overlay, which only instances with the // execution bridge have; the image's own com.android.vending, a license-check placeholder, is hidden there. // -// Files is the table of those files; Extract takes them out of the Play image's system.img (GPT, then the dynamic -// partitions of its super partition, then ext4) without mounting anything. +// Files is the Windows table; Extract reads a profile's files from system.img (GPT, then the dynamic partitions of +// its super partition, then ext4 or EROFS) without mounting anything. package playstore import "path" diff --git a/internal/playstore/image.go b/internal/playstore/image.go index 4d1d7b0..582ff90 100644 --- a/internal/playstore/image.go +++ b/internal/playstore/image.go @@ -1,8 +1,8 @@ package playstore // Reading files out of an Android emulator system.img, read-only: a GPT disk whose "super" partition holds the -// dynamic (logical) partitions (liblp metadata), each an ext4 file system. Only what these images use is supported: -// linear extents on the super partition itself, ext4 with extents (no inline data, no meta_bg). Anything else is an +// dynamic (logical) partitions (liblp metadata), with ext4 or EROFS file systems. Only what these images use is +// supported: linear super extents, ext4 extents, and EROFS flat files or compact LZ4 clusters. Anything else is an // error, never a guess; the callers check every file against its pinned size and SHA-256 anyway. import ( @@ -35,7 +35,7 @@ func Extract(img string, files []File, dir string) error { if err != nil { return err } - fss := map[string]*ext4{} + fss := map[string]imageFS{} for _, file := range files { fs := fss[file.Partition] if fs == nil { @@ -43,7 +43,7 @@ func Extract(img string, files []File, dir string) error { if !ok { return fmt.Errorf("镜像里没有 %s 分区", file.Partition) } - if fs, err = openExt4(p); err != nil { + if fs, err = openImageFS(p); err != nil { return fmt.Errorf("%s 分区:%w", file.Partition, err) } fss[file.Partition] = fs @@ -55,21 +55,23 @@ func Extract(img string, files []File, dir string) error { return nil } -func extractOne(fs *ext4, file File, dir string) error { - ino, err := fs.lookup(file.Path) - if err != nil { - return err - } - in, err := fs.inode(ino) - if err != nil { - return err - } - if in.mode&0xF000 != 0x8000 { - return errors.New("不是普通文件") +// imageFS checks each requested file's type and pinned size before copying its bytes. +type imageFS interface { + copyFile(path string, size int64, w io.Writer) (int64, error) +} + +func openImageFS(p *partition) (imageFS, error) { + magic := make([]byte, 4) + if _, err := p.ReadAt(magic, 1024); err != nil { + return nil, err } - if in.size != file.Size { - return fmt.Errorf("大小是 %d,应为 %d", in.size, file.Size) + if binary.LittleEndian.Uint32(magic) == erofsMagic { + return openEROFS(p) } + return openExt4(p) +} + +func extractOne(fs imageFS, file File, dir string) error { dst := filepath.Join(dir, file.Name) tmp := dst + ".part" out, err := os.Create(tmp) @@ -77,7 +79,7 @@ func extractOne(fs *ext4, file File, dir string) error { return err } h := sha256.New() - _, err = fs.copyData(in, io.MultiWriter(out, h)) + _, err = fs.copyFile(file.Path, file.Size, io.MultiWriter(out, h)) if cerr := out.Close(); err == nil { err = cerr } @@ -358,6 +360,24 @@ func openExt4(p *partition) (*ext4, error) { return fs, nil } +func (fs *ext4) copyFile(path string, size int64, w io.Writer) (int64, error) { + ino, err := fs.lookup(path) + if err != nil { + return 0, err + } + in, err := fs.inode(ino) + if err != nil { + return 0, err + } + if in.mode&0xF000 != 0x8000 { + return 0, errors.New("不是普通文件") + } + if in.size != size { + return 0, fmt.Errorf("大小是 %d,应为 %d", in.size, size) + } + return fs.copyData(in, w) +} + func (fs *ext4) read(off, n int64) ([]byte, error) { if off < 0 || n < 0 || off+n > fs.size { return nil, errors.New("读的范围超出文件系统") diff --git a/internal/playstore/profile.go b/internal/playstore/profile.go new file mode 100644 index 0000000..205f471 --- /dev/null +++ b/internal/playstore/profile.go @@ -0,0 +1,62 @@ +package playstore + +import "runtime" + +// Profile binds an optional Play component to one host's pinned guest image. +// Keeping the tables separate prevents accepting x86 files for an ARM64 SDK. +type Profile struct { + API, ABI, Revision string + ImageURL, ImageSHA1, ImageSHA256 string + ImageSize, ImageMemberSize int64 + ImageMember, ArchiveRoot, ZipName string + Files []File +} + +func (p Profile) ExtractedBytes() int64 { + var n int64 + for _, f := range p.Files { + n += f.Size + } + return n +} + +// WindowsProfile preserves the API34 x86_64 component and its existing pins. +func WindowsProfile() Profile { + return Profile{API: "34", ABI: "x86_64", Revision: Revision, + ImageURL: ImageURL, ImageSize: ImageSize, ImageSHA1: ImageSHA1, + ImageMember: ImageMember, ImageMemberSize: ImageMemberSize, + ArchiveRoot: "x86_64", ZipName: "google_apis_playstore-x86_64-34_r14.zip", Files: Files} +} + +// CurrentProfile is the host's component table. Unsupported hosts retain the +// existing Windows status shape; the component entry points reject downloads. +func CurrentProfile() Profile { + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + return darwinProfile() + } + return WindowsProfile() +} + +func darwinProfile() Profile { + return Profile{API: "35", ABI: "arm64-v8a", Revision: "9", + ImageURL: "sys-img/google_apis_playstore/arm64-v8a-35_r09.zip", + ImageSize: 1789211399, ImageSHA1: "b01416bc0bca12a85295dad179f9b8a1cae3d1e6", + ImageSHA256: "97cbd9b347a92ce6cfeb9817504cca162d9a0f230ee38cd216e21920ae91fea8", + ImageMember: "arm64-v8a/system.img", ImageMemberSize: 2203058176, + ArchiveRoot: "arm64-v8a", ZipName: "google_apis_playstore-arm64-v8a-35_r09.zip", + Files: darwinFiles} +} + +// Filled from the size/SHA-1-verified official API35 ARM64 r9 Play archive. +var darwinFiles = []File{ + {"Phonesky.apk", "product", "priv-app/Phonesky/Phonesky.apk", 78672456, + "27ec8f3dbb69caa2176b6faa4fc6f59ceb1a134ccdea0933b58908ac28c3ff71"}, + {"PrebuiltGmsCore.apk", "product", "priv-app/PrebuiltGmsCore/PrebuiltGmsCore.apk", 236728460, + "5e093367651d0db1478e58a5cac463cbaf684edbe6ffc42743ce1390e545dee4"}, + {"GoogleServicesFramework.apk", "system_ext", "priv-app/GoogleServicesFramework/GoogleServicesFramework.apk", 5290526, + "3d17213740c99cfa7961bb3d9509d6783914b2b7f634e366c4e73f5a0ad293eb"}, + {"PartnerSetupPrebuilt.apk", "product", "priv-app/PartnerSetupPrebuilt/PartnerSetupPrebuilt.apk", 1444170, + "d9d0c6af51017eccff01a18c9593db14edba40f49f3bfd9764b79ad70a8496db"}, + {"play_store_fsi_cert.der", "product", "etc/security/fsverity/play_store_fsi_cert.der", 1422, + "37aabd485044e3af6d84755268faf8392e226ca0d188be6a80335525ce0ec6ff"}, +} diff --git a/internal/root/components.go b/internal/root/components.go index a70062d..dc3342e 100644 --- a/internal/root/components.go +++ b/internal/root/components.go @@ -110,26 +110,34 @@ const PlayStoreName = "Google Play 商店" // services, GSF and Partner Setup taken out of Google's Play image of the same release, kept in \playstore. It is // not in SDKComponents, so check, components status and AUTO-MAS never count it as missing; only `components fetch // --play-store` fetches it, and only instances created or set up with the store use it. Complete pins every file's -// SHA-256. The zip unpacks into one 3 GB system.img that fetch reads the files out of and deletes (ExtractedBytes +// SHA-256. The zip unpacks into a host-specific system.img that fetch reads the files out of and deletes (ExtractedBytes // counts both); on hosts without a catalogue the URL is "". func PlayStoreComponent() SDKComponent { - files := make([]string, len(playstore.Files)) + p := playstore.CurrentProfile() + files := make([]string, len(p.Files)) hashes := map[string]string{} - for k, f := range playstore.Files { + for k, f := range p.Files { files[k] = f.Name hashes[f.Name] = f.SHA256 } c := SDKComponent{ID: "play-store", Name: PlayStoreName, - FetchName: "Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)", Version: playstore.Revision, + FetchName: "Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)", Version: p.Revision, Target: PlayStoreDir, StatusFiles: files, KeyFiles: files, KeyHashes: hashes} - if runtime.GOOS == "windows" { - c.URL, c.Size, c.SHA1, c.ArchiveRoot = playstore.ImageURL, playstore.ImageSize, playstore.ImageSHA1, "x86_64" - // the Google APIs image's zip has the same last part (x86_64-34_r14.zip): another name, or a .part of one - // would be resumed as the other - c.ZipName = "google_apis_playstore-x86_64-34_r14.zip" - c.ExtractedBytes = playstore.ImageMemberSize + playstore.ExtractedBytes() + darwin := runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" + if runtime.GOOS == "windows" || darwin { + c.URL, c.Size, c.SHA1, c.ArchiveRoot = p.ImageURL, p.ImageSize, p.ImageSHA1, p.ArchiveRoot + c.SHA256, c.ZipName = p.ImageSHA256, p.ZipName + c.ExtractedBytes = p.ImageMemberSize + p.ExtractedBytes() c.Properties = "Pkg.Desc=Google Play Store, Google Play services, GSF and Partner Setup from " + - "system-images;android-34;google_apis_playstore;x86_64.\nPkg.Revision=" + playstore.Revision + "\n" + "system-images;android-" + p.API + ";google_apis_playstore;" + p.ABI + ".\nPkg.Revision=" + p.Revision + "\n" + } + if darwin { + c.FetchName = "Google Play 商店(取自 Android 15 Google Play arm64-v8a 镜像 r9)" + c.LicenseID, c.LicenseManifest = "android-sdk-arm-dbt-license", "sys-img/google_apis_playstore/sys-img2-3.xml" + c.LicenseSHA256 = "0682da42e0ca119ce29503c265587f8ae17277094f9262b54c1d2bb99557c6f5" + c.RequiredProperties = map[string]string{"SystemImage.Abi": p.ABI, "AndroidVersion.ApiLevel": p.API, + "SystemImage.TagId": "google_apis_playstore"} + c.Properties += "SystemImage.Abi=" + p.ABI + "\nAndroidVersion.ApiLevel=" + p.API + "\nSystemImage.TagId=google_apis_playstore\n" } return c } @@ -225,6 +233,12 @@ func (c SDKComponent) Complete(dir string) bool { return false } } + return c.MetadataMatches(dir) +} + +// MetadataMatches checks the pinned revision and guest identity without hashing +// large files. Status polling uses it; publication still requires Complete. +func (c SDKComponent) MetadataMatches(dir string) bool { data, err := ReadFileShared(filepath.Join(dir, "source.properties")) if err != nil { return false diff --git a/internal/ui/API.md b/internal/ui/API.md index b54c37e..b84709e 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -139,9 +139,11 @@ data: {} 每 15 秒 ### Google Play 商店 `PlayStoreStatus`、`PlayStoreImportResult`、`PlayStoreResult` +Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9,归档 `arm64-v8a-35_r09.zip`;实例内装卸仍返回 `unsupported`。Windows 保持 API34 x86_64 r14。`PlayStoreStatus` 增加 `archive`(可导入的官方归档名)、`download_bytes`(归档大小)和 `install_supported`(当前宿主是否已支持实例装卸);界面按这些字段展示大小、导入提示和能力边界。macOS 的 `ready` 同时检查 `source.properties` 的 ABI、API 和镜像类型,仍不替代导入/下载时的摘要校验。 + 商店是可选的:根目录里留五个文件(`sdk\playstore\`,补全组件时勾选下载,或者导入自己的文件),每台实例要的时候再装进去;新建实例不带。 -- `PlayStoreStatus`(`GET /api/components` 的 `play_store`,和 `modavd components status --json` 的 `data.play_store` 相同):`{"dir","ready","source","version","files":[{"name","guest","size","present"}]}`。 +- `PlayStoreStatus`(`GET /api/components` 的 `play_store`,和 `modavd components status --json` 的 `data.play_store` 相同):`{"dir","ready","source","version","archive","download_bytes","install_supported","files":[{"name","guest","size","present"}]}`。 - `ready`:五个文件都在、`source.properties` 的版本对(只看大小,SHA-256 在放进来和装进实例时核对)。实例的「补全 Google Play 商店」按钮按它决定是直接装,还是先引导去补全组件。 - `source`:`download`(补全组件时下载的)、`import`(用自己的文件导入的),没有或来历不明是 `""`。 - `files` 固定五项,顺序同文档站:`Phonesky.apk`、`PrebuiltGmsCore.apk`、`GoogleServicesFramework.apk`、`PartnerSetupPrebuilt.apk`、`play_store_fsi_cert.der`;`guest` 是装进实例后的路径,`present` 是这个文件在、大小对。 diff --git a/internal/ui/playstore.go b/internal/ui/playstore.go index 48dea5e..98381f1 100644 --- a/internal/ui/playstore.go +++ b/internal/ui/playstore.go @@ -25,9 +25,9 @@ const maxImportFiles = 8 var ( // maxImportPart is the largest file worth receiving: the Play image's zip (the five files are far smaller). - maxImportPart int64 = playstore.ImageSize + maxImportPart int64 = playstore.CurrentProfile().ImageSize // maxImportBody bounds a whole import upload: the zip and the five files, with room for the multipart framing. - maxImportBody = maxImportPart + 2*playstore.ExtractedBytes() + 1<<20 + maxImportBody = maxImportPart + 2*playstore.CurrentProfile().ExtractedBytes() + 1<<20 ) // handlePlayStore is POST /api/instances/{i}/playstore-install and playstore-uninstall: a job running emu's diff --git a/internal/ui/web/js/diagnostics.js b/internal/ui/web/js/diagnostics.js index f9abb51..d7bf663 100644 --- a/internal/ui/web/js/diagnostics.js +++ b/internal/ui/web/js/diagnostics.js @@ -71,6 +71,11 @@ const MAX_IMPORT = 8; // the server's limit: the five files and the zip, with ro let upload = null; // {n, bytes} while an import's files are being handed to the server export const storeStatus = () => (S.components && S.components.play_store) || null; +const storeArchive = () => (storeStatus() || {}).archive || 'x86_64-34_r14.zip'; +const storeDownload = () => `${(((storeStatus() || {}).download_bytes || 1510752654) / 1e9).toFixed(1)} GB`; +const storeInstallNote = () => (storeStatus() || {}).install_supported === false + ? '当前平台尚未开放实例内安装与移除。' + : '新建的实例不带商店,要用的实例在「更多操作」里补全。'; function storeReadyText(st) { const bits = [STORE_FROM[st.source], st.version && `r${st.version}`].filter(Boolean); @@ -85,7 +90,7 @@ export async function importPlayStore(files) { files = [...files]; if (!files.length) return null; if (activeJob('components') || activeJob('playstore-import') || upload) throw refusal('组件正在补全或导入', '等它做完再导入'); - if (files.length > MAX_IMPORT) throw refusal(`一次最多选 ${MAX_IMPORT} 个文件`, '只要 Google Play 版镜像的压缩包 x86_64-34_r14.zip,或者商店的五个文件'); + if (files.length > MAX_IMPORT) throw refusal(`一次最多选 ${MAX_IMPORT} 个文件`, `只要 Google Play 版镜像的压缩包 ${storeArchive()},或者商店的五个文件`); const empty = files.find((f) => !f.size); if (empty) throw refusal(`${empty.name} 是空文件`, '换成 Google Play 版镜像的压缩包,或者商店的五个文件'); const fd = new FormData(); @@ -123,8 +128,8 @@ export function storeBox(mode, checked) { if (mode === 'fetch') { b.box = h('input', { type: 'checkbox', id: 'lic-store', checked: !!checked, 'aria-describedby': 'lic-store-h' }); b.choose = h('div', { class: 'store-choose' }, - h('label', { class: 'checkbox' }, b.box, h('span', { text: '同时补全 Google Play 商店服务(约 1.5 GB,可选)' })), - h('p', { class: 'f-hint', id: 'lic-store-h', text: '勾上就一起从 Google 官方下载 Google Play 版系统镜像(同样受这份协议约束),只留商店的五个文件;新建的实例不带商店,要用的实例在「更多操作」里补全。已经有镜像压缩包 x86_64-34_r14.zip 或那五个文件的,直接导入,不用下载:' }), + h('label', { class: 'checkbox' }, b.box, b.label = h('span')), + b.note = h('p', { class: 'f-hint', id: 'lic-store-h' }), h('div', { class: 'store-own' }, b.own)); b.el = h('div', { class: 'store-opt' }, b.choose, b.state, b.running, b.err, b.input); } else { @@ -133,7 +138,7 @@ export function storeBox(mode, checked) { b.choose = h('div', { class: 'diag-actions' }, b.get, b.own); b.el = h('section', { class: 'store-box', 'aria-labelledby': 'store-box-t', tabindex: '-1' }, h('div', { class: 'store-head' }, h('strong', { id: 'store-box-t', text: 'Google Play 商店服务' }), h('span', { class: 'tag', text: '可选' }), h('span', { class: 'spacer' }), b.pill), - h('p', { class: 'store-note', text: '给实例补全 Google Play 商店要用。从 Google 官方下载 Google Play 版系统镜像(约 1.5 GB),只留商店的五个文件;或者用自己的镜像压缩包 x86_64-34_r14.zip、那五个文件导入。新建的实例不带商店。' }), + b.note = h('p', { class: 'store-note' }), b.state, b.running, b.err, b.choose, b.input); } b.wanted = () => mode === 'fetch' && !!b.box.checked && !b.box.disabled && !(storeStatus() || {}).ready; @@ -150,6 +155,8 @@ export function fillStoreBox(b) { const last = imp ? null : latestJob('playstore-import'); const failed = b.local || (last && last.state === 'failed' ? last.error || {} : null); b.choose.hidden = ready; + if (b.label) setText(b.label, `同时补全 Google Play 商店服务(约 ${storeDownload()},可选)`); + setText(b.note, `从 Google 官方下载 Google Play 版系统镜像(约 ${storeDownload()}),同样受许可协议约束,只保留商店的五个文件;也可导入 ${storeArchive()} 或五个文件。${storeInstallNote()}`); if (b.mode === 'check') { setText(b.pill, ready ? '已就绪' : st ? '没有' : '未知'); b.pill.className = 'pill tone-' + (ready ? 'ok' : 'idle'); @@ -159,7 +166,7 @@ export function fillStoreBox(b) { attr(b.own, 'aria-disabled', run || upload ? true : null); } b.state.hidden = !ready; - if (ready) setText(b.state, `${storeReadyText(st)}。要用的实例在「更多操作」里补全商店。`); + if (ready) setText(b.state, `${storeReadyText(st)}。${storeInstallNote()}`); b.running.hidden = !(run || upload); b.cancel.hidden = !run; if (run) {