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) {