From a500d8642f715cf4d18e43bda180355b578e13ae Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sat, 13 Jun 2026 09:23:38 +0200 Subject: [PATCH] feat: return installed app metadata from apps install --- commands/apps.go | 21 +++++- go.mod | 1 + go.sum | 2 + utils/appmeta.go | 135 ++++++++++++++++++++++++++++++++++++++ utils/appmeta_test.go | 117 +++++++++++++++++++++++++++++++++ utils/testdata/sample.apk | Bin 0 -> 963 bytes 6 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 utils/appmeta.go create mode 100644 utils/appmeta_test.go create mode 100644 utils/testdata/sample.apk diff --git a/commands/apps.go b/commands/apps.go index e4f0af2a..49b03193 100644 --- a/commands/apps.go +++ b/commands/apps.go @@ -115,6 +115,13 @@ type InstallAppRequest struct { SigningIdentity string `json:"signingIdentity"` } +// InstallAppResult is returned on a successful install, including the app +// metadata parsed from the installed file when available. +type InstallAppResult struct { + Message string `json:"message"` + App *utils.AppMetadata `json:"app,omitempty"` +} + func InstallAppCommand(req InstallAppRequest) *CommandResponse { if req.Path == "" { return NewErrorResponse(fmt.Errorf("path is required")) @@ -151,9 +158,19 @@ func InstallAppCommand(req InstallAppRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to install app on device %s: %w", targetDevice.ID(), err)) } - return NewSuccessResponse(MessageResult{ + result := InstallAppResult{ Message: fmt.Sprintf("Installed app from '%s' on device %s", req.Path, targetDevice.ID()), - }) + } + + // metadata extraction is best-effort: a parse failure must not turn a + // successful install into an error. + if meta, err := utils.ParseAppMetadata(req.Path); err != nil { + utils.Verbose("failed to parse app metadata from %s: %v", req.Path, err) + } else { + result.App = meta + } + + return NewSuccessResponse(result) } type AppPathRequest struct { diff --git a/go.mod b/go.mod index 9701d156..fdd2889e 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/sevlyar/go-daemon v0.1.6 + github.com/shogo82148/androidbinary v1.0.5 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index ff3a28cd..653cc29d 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs= github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE= +github.com/shogo82148/androidbinary v1.0.5 h1:7afvcNw+vT84R0ugrL/u/DIrGYylC66yNvt0Y0j7rrM= +github.com/shogo82148/androidbinary v1.0.5/go.mod h1:FzpR5bLAXR3VsAUG4BRCFaUm0WV6YD4Ldu+m05tr9Vk= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= diff --git a/utils/appmeta.go b/utils/appmeta.go new file mode 100644 index 00000000..11a26fc5 --- /dev/null +++ b/utils/appmeta.go @@ -0,0 +1,135 @@ +package utils + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/shogo82148/androidbinary/apk" + "howett.net/plist" +) + +// AppMetadata holds identity and version information parsed from an app file +// (.apk, .ipa, .zip, or .app). Field names are unified across platforms: +// PackageName is the Android package / iOS CFBundleIdentifier, Version is the +// Android versionName / iOS CFBundleShortVersionString (marketing version), and +// VersionCode is the Android versionCode / iOS CFBundleVersion (build number). +type AppMetadata struct { + PackageName string `json:"packageName"` + Version string `json:"version,omitempty"` + VersionCode string `json:"versionCode,omitempty"` +} + +// ParseAppMetadata reads identity and version metadata from an app file, +// dispatching by extension. It parses the file locally and does not touch any +// device. +func ParseAppMetadata(path string) (*AppMetadata, error) { + lower := strings.ToLower(path) + switch { + case strings.HasSuffix(lower, ".apk"): + return parseApkMetadata(path) + case strings.HasSuffix(lower, ".ipa"), strings.HasSuffix(lower, ".zip"): + return parseIpaMetadata(path) + case strings.HasSuffix(lower, ".app"): + return parseAppDirMetadata(path) + default: + return nil, fmt.Errorf("unsupported app file type: %s", path) + } +} + +func parseApkMetadata(path string) (*AppMetadata, error) { + pkg, err := apk.OpenFile(path) + if err != nil { + return nil, fmt.Errorf("failed to open apk: %w", err) + } + defer func() { _ = pkg.Close() }() + + meta := &AppMetadata{ + PackageName: pkg.PackageName(), + } + + manifest := pkg.Manifest() + if versionName, err := manifest.VersionName.String(); err == nil { + meta.Version = versionName + } + if versionCode, err := manifest.VersionCode.Int32(); err == nil { + meta.VersionCode = strconv.FormatInt(int64(versionCode), 10) + } + + return meta, nil +} + +// parseIpaMetadata reads the top-level app's Info.plist from an .ipa or .zip +// archive. It works for both .ipa (Payload/Foo.app/Info.plist) and simulator +// .zip (Foo.app/Info.plist) layouts. +func parseIpaMetadata(path string) (*AppMetadata, error) { + reader, err := zip.OpenReader(path) + if err != nil { + return nil, fmt.Errorf("failed to open archive: %w", err) + } + defer func() { _ = reader.Close() }() + + for _, file := range reader.File { + if !isAppInfoPlist(file.Name) { + continue + } + + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open Info.plist: %w", err) + } + + data, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read Info.plist: %w", err) + } + + return decodeInfoPlist(data) + } + + return nil, fmt.Errorf("no app Info.plist found in %s", path) +} + +func parseAppDirMetadata(path string) (*AppMetadata, error) { + data, err := os.ReadFile(filepath.Join(path, "Info.plist")) + if err != nil { + return nil, fmt.Errorf("failed to read Info.plist: %w", err) + } + + return decodeInfoPlist(data) +} + +// isAppInfoPlist reports whether a zip entry name is the top-level app bundle's +// Info.plist, e.g. "Payload/Foo.app/Info.plist" or "Foo.app/Info.plist". A +// nested bundle's plist (frameworks, plugins) has more path segments and is +// excluded. +func isAppInfoPlist(name string) bool { + name = strings.TrimPrefix(name, "Payload/") + parts := strings.Split(name, "/") + return len(parts) == 2 && strings.HasSuffix(parts[0], ".app") && parts[1] == "Info.plist" +} + +func decodeInfoPlist(data []byte) (*AppMetadata, error) { + // infoPlist mirrors the keys we extract from an iOS Info.plist. + type infoPlist struct { + CFBundleIdentifier string `plist:"CFBundleIdentifier"` + CFBundleShortVersionString string `plist:"CFBundleShortVersionString"` + CFBundleVersion string `plist:"CFBundleVersion"` + } + + var info infoPlist + if _, err := plist.Unmarshal(data, &info); err != nil { + return nil, fmt.Errorf("failed to parse Info.plist: %w", err) + } + + return &AppMetadata{ + PackageName: info.CFBundleIdentifier, + Version: info.CFBundleShortVersionString, + VersionCode: info.CFBundleVersion, + }, nil +} diff --git a/utils/appmeta_test.go b/utils/appmeta_test.go new file mode 100644 index 00000000..7e4cc685 --- /dev/null +++ b/utils/appmeta_test.go @@ -0,0 +1,117 @@ +package utils + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sampleInfoPlist is a minimal XML Info.plist; howett.net/plist parses both XML +// and binary plists, so XML keeps the test fixtures readable. +const sampleInfoPlist = ` + + + + CFBundleIdentifier + com.mobilenext.playground + CFBundleShortVersionString + 1.4.0 + CFBundleVersion + 42 + +` + +// writeZip writes a zip file with the given entries to a temp file and returns its path. +func writeZip(t *testing.T, entries map[string]string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "app.zip") + + f, err := os.Create(path) + require.NoError(t, err) + + w := zip.NewWriter(f) + for name, content := range entries { + entry, err := w.Create(name) + require.NoError(t, err) + _, err = entry.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + require.NoError(t, f.Close()) + + return path +} + +func TestParseAppMetadataFromIpaLayout(t *testing.T) { + ipa := writeZip(t, map[string]string{ + "Payload/Playground.app/Info.plist": sampleInfoPlist, + }) + // rename to .ipa so extension dispatch picks the iOS path + ipaPath := ipa + ".ipa" + require.NoError(t, os.Rename(ipa, ipaPath)) + + meta, err := ParseAppMetadata(ipaPath) + require.NoError(t, err) + assert.Equal(t, "com.mobilenext.playground", meta.PackageName) + assert.Equal(t, "1.4.0", meta.Version) + assert.Equal(t, "42", meta.VersionCode) +} + +func TestParseAppMetadataFromSimulatorZipLayout(t *testing.T) { + // simulator .zip has the .app at the archive root, not under Payload/ + zipPath := writeZip(t, map[string]string{ + "Playground.app/Info.plist": sampleInfoPlist, + }) + + meta, err := ParseAppMetadata(zipPath) + require.NoError(t, err) + assert.Equal(t, "com.mobilenext.playground", meta.PackageName) + assert.Equal(t, "1.4.0", meta.Version) + assert.Equal(t, "42", meta.VersionCode) +} + +func TestParseAppMetadataIgnoresNestedBundlePlists(t *testing.T) { + // a framework's Info.plist must not shadow the top-level app's + ipa := writeZip(t, map[string]string{ + "Payload/Playground.app/Frameworks/Other.framework/Info.plist": `CFBundleIdentifiercom.other.framework`, + "Payload/Playground.app/Info.plist": sampleInfoPlist, + }) + ipaPath := ipa + ".ipa" + require.NoError(t, os.Rename(ipa, ipaPath)) + + meta, err := ParseAppMetadata(ipaPath) + require.NoError(t, err) + assert.Equal(t, "com.mobilenext.playground", meta.PackageName) +} + +func TestParseAppMetadataFromAppDirectory(t *testing.T) { + appDir := filepath.Join(t.TempDir(), "Playground.app") + require.NoError(t, os.Mkdir(appDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(appDir, "Info.plist"), []byte(sampleInfoPlist), 0o600)) + + meta, err := ParseAppMetadata(appDir) + require.NoError(t, err) + assert.Equal(t, "com.mobilenext.playground", meta.PackageName) + assert.Equal(t, "1.4.0", meta.Version) + assert.Equal(t, "42", meta.VersionCode) +} + +func TestParseAppMetadataFromApk(t *testing.T) { + // sample.apk is a stripped-down fixture: just the binary AndroidManifest.xml + // plus an empty resources.arsc (the parser requires the latter to exist). + // package/version are literal manifest attributes, so no resource table is needed. + meta, err := ParseAppMetadata("testdata/sample.apk") + require.NoError(t, err) + assert.Equal(t, "com.example.helloworld", meta.PackageName) + assert.Equal(t, "1.0", meta.Version) + assert.Equal(t, "1", meta.VersionCode) +} + +func TestParseAppMetadataRejectsUnknownExtension(t *testing.T) { + _, err := ParseAppMetadata("/tmp/whatever.txt") + assert.Error(t, err) +} diff --git a/utils/testdata/sample.apk b/utils/testdata/sample.apk new file mode 100644 index 0000000000000000000000000000000000000000..5af8e1c56435dae4325f95a8f250abc020084236 GIT binary patch literal 963 zcmWIWW@Zs#U|`^2_!+g{YtDbmpO={!7z)@K7=(eMj(I6X`I#xciFui6sl_FF6}dT6 zLwx;(14V2vFDzMbRBS?!m+PMhNy&>7GGZhIeN7z2B_gBkawQTjNd|;(DoKsm^+0^X zNB;DdxckyK>HESk28Px%{k9Fy_5Bk*hVLoBZ40%{_y&v z@+|g&Uf`3=*%``C+`n%e7P2=#@#US3lko0c#!k1DKF^qE9r5-PlUB#eo5#$4?&$ZQ zD93oe*5=-Yx441xAlHyhiCnl zs@?w6t7gF;2Ghx0TrqDiuJ>Ilrd!a-Wq4n}ys$O@h>q1P-*ta`_OG{mcWM2}4%u5~ zwaS0*t>w1WH8C~Ys_oF*b44uKd-cs!aXtw_gS4&ZZT{J+&7HFR=lhBGFa1d>3Z1;$ z|5({oIIsw^2+L4 ztUv2Kj#PNQJ5-E$=?Z+Dp4q2oqh=LO_*elPT||LoqkeDB7yCt@1nYr3|m{`C8|_2_BiQ(rUpRTiszA3HVgxcu5=i;a(?)bA8- zuJ0;4Q;^)Iy?S2J)L9R;wg}7i{V$2n^OieZ_5ZTXDz*MOQ{B&w_MA7N>Or>_pkg*r$vqCJSl6w zvtnxW*(c8;cJc}bFWVoyY+3iR=bkZlkEr@Dvd#PH^ZjC;#M`#3$8%y9nJ0cs|MlXm z#9m2ned*~RuKMtD+RuFHdFc8D+v6?k)8B2H{BZK_|Iz`#JirA^?FktO8Fk@QI$_A2W0>WA# KZ2(lkzyJUT{-47D literal 0 HcmV?d00001