diff --git a/commands/apps.go b/commands/apps.go
index e4f0af2..49b0319 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 9701d15..fdd2889 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 ff3a28c..653cc29 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 0000000..11a26fc
--- /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 0000000..7e4cc68
--- /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 0000000..5af8e1c
Binary files /dev/null and b/utils/testdata/sample.apk differ