From 808a8335e191c38c706e7efeba9c35d36d0d9a66 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:36:41 +0200 Subject: [PATCH 1/5] asc: localizations, app infos, categories and screenshot sets appStoreVersionLocalizations and appInfoLocalizations as attribute maps, appInfos with their categories, and the appScreenshotSets / appScreenshots flow: reserve, PUT the upload operations, commit with the MD5 checksum, poll assetDeliveryState. --- internal/asc/localizations.go | 186 +++++++++++++++++++++ internal/asc/screenshots.go | 274 +++++++++++++++++++++++++++++++ internal/asc/screenshots_test.go | 190 +++++++++++++++++++++ internal/asc/versions.go | 14 ++ 4 files changed, 664 insertions(+) create mode 100644 internal/asc/localizations.go create mode 100644 internal/asc/screenshots.go create mode 100644 internal/asc/screenshots_test.go diff --git a/internal/asc/localizations.go b/internal/asc/localizations.go new file mode 100644 index 0000000..b999daf --- /dev/null +++ b/internal/asc/localizations.go @@ -0,0 +1,186 @@ +package asc + +import ( + "context" + "encoding/json" + "net/url" +) + +// Attributes of appStoreVersionLocalizations. +const ( + AttrDescription = "description" + AttrKeywords = "keywords" + AttrWhatsNew = "whatsNew" + AttrPromotionalText = "promotionalText" + AttrMarketingURL = "marketingUrl" + AttrSupportURL = "supportUrl" +) + +// Attributes of appInfoLocalizations. +const ( + AttrName = "name" + AttrSubtitle = "subtitle" + AttrPrivacyPolicyURL = "privacyPolicyUrl" +) + +// Localization is one locale of an App Store version (appStoreVersionLocalizations) +// or of the app info (appInfoLocalizations). +type Localization struct { + ID string + Locale string + // Fields maps attribute names (AttrDescription, AttrName, ...) to their + // values; attributes that are null in App Store Connect are absent. + Fields map[string]string +} + +func toLocalization(r Resource[map[string]any]) Localization { + l := Localization{ID: r.ID, Fields: map[string]string{}} + for k, v := range r.Attributes { + s, ok := v.(string) + switch { + case !ok: + case k == "locale": + l.Locale = s + default: + l.Fields[k] = s + } + } + return l +} + +func listLocalizations(ctx context.Context, c *Client, path string) ([]Localization, error) { + rs, err := getAll[map[string]any](ctx, c, path, nil) + if err != nil { + return nil, err + } + locs := make([]Localization, 0, len(rs)) + for _, r := range rs { + locs = append(locs, toLocalization(r)) + } + return locs, nil +} + +func createLocalization(ctx context.Context, c *Client, resourceType, parentRel, parentType, parentID, locale string, fields map[string]string) (*Localization, error) { + attrs := map[string]any{"locale": locale} + for k, v := range fields { + attrs[k] = v + } + req := Resource[map[string]any]{Type: resourceType, Attributes: attrs, Relationships: Relationships{parentRel: ToOne(parentType, parentID)}} + r, err := post[map[string]any, map[string]any](ctx, c, "/v1/"+resourceType, req) + if err != nil { + return nil, err + } + l := toLocalization(*r) + return &l, nil +} + +func updateLocalization(ctx context.Context, c *Client, resourceType, id string, fields map[string]string) error { + attrs := make(map[string]any, len(fields)) + for k, v := range fields { + attrs[k] = v + } + req := Resource[map[string]any]{Type: resourceType, ID: id, Attributes: attrs} + return c.Patch(ctx, "/v1/"+resourceType+"/"+id, Document[Resource[map[string]any]]{Data: req}, nil) +} + +// ListVersionLocalizations lists the locales of an App Store version. +func (c *Client) ListVersionLocalizations(ctx context.Context, versionID string) ([]Localization, error) { + return listLocalizations(ctx, c, "/v1/appStoreVersions/"+versionID+"/appStoreVersionLocalizations") +} + +// CreateVersionLocalization adds a locale to an App Store version. +func (c *Client) CreateVersionLocalization(ctx context.Context, versionID, locale string, fields map[string]string) (*Localization, error) { + return createLocalization(ctx, c, "appStoreVersionLocalizations", "appStoreVersion", "appStoreVersions", versionID, locale, fields) +} + +// UpdateVersionLocalization sets the given attributes of a version locale. +func (c *Client) UpdateVersionLocalization(ctx context.Context, id string, fields map[string]string) error { + return updateLocalization(ctx, c, "appStoreVersionLocalizations", id, fields) +} + +// ListAppInfoLocalizations lists the locales of an app info record. +func (c *Client) ListAppInfoLocalizations(ctx context.Context, appInfoID string) ([]Localization, error) { + return listLocalizations(ctx, c, "/v1/appInfos/"+appInfoID+"/appInfoLocalizations") +} + +// CreateAppInfoLocalization adds a locale to an app info record; App Store +// Connect requires AttrName. +func (c *Client) CreateAppInfoLocalization(ctx context.Context, appInfoID, locale string, fields map[string]string) (*Localization, error) { + return createLocalization(ctx, c, "appInfoLocalizations", "appInfo", "appInfos", appInfoID, locale, fields) +} + +// UpdateAppInfoLocalization sets the given attributes of an app info locale. +func (c *Client) UpdateAppInfoLocalization(ctx context.Context, id string, fields map[string]string) error { + return updateLocalization(ctx, c, "appInfoLocalizations", id, fields) +} + +// App info states (AppInfo.State) a new version's metadata can be edited in. +const ( + AppInfoStatePrepareForSubmission = "PREPARE_FOR_SUBMISSION" + AppInfoStateDeveloperRejected = "DEVELOPER_REJECTED" + AppInfoStateRejected = "REJECTED" +) + +// AppInfo is the version-independent part of an app's store listing: name, +// subtitle, privacy policy and categories. An app has one live record and, +// while a version is being prepared, an editable one. +type AppInfo struct { + ID string + State string + PrimaryCategoryID string + SecondaryCategoryID string +} + +// Editable reports whether the record accepts changes. +func (a *AppInfo) Editable() bool { + switch a.State { + case AppInfoStatePrepareForSubmission, AppInfoStateDeveloperRejected, AppInfoStateRejected: + return true + } + return false +} + +type appInfoAttributes struct { + State string `json:"state,omitempty"` + AppStoreState string `json:"appStoreState,omitempty"` +} + +// ListAppInfos lists the app's info records with their categories. +func (c *Client) ListAppInfos(ctx context.Context, appID string) ([]AppInfo, error) { + rs, err := getAll[appInfoAttributes](ctx, c, "/v1/apps/"+appID+"/appInfos", url.Values{"include": {"primaryCategory,secondaryCategory"}}) + if err != nil { + return nil, err + } + infos := make([]AppInfo, 0, len(rs)) + for _, r := range rs { + info := AppInfo{ID: r.ID, State: r.Attributes.State} + if info.State == "" { + info.State = r.Attributes.AppStoreState + } + if l, ok := r.Relationships.One("primaryCategory"); ok { + info.PrimaryCategoryID = l.ID + } + if l, ok := r.Relationships.One("secondaryCategory"); ok { + info.SecondaryCategoryID = l.ID + } + infos = append(infos, info) + } + return infos, nil +} + +// UpdateAppInfoCategories sets the categories that are non-nil; an empty +// string clears the category. +func (c *Client) UpdateAppInfoCategories(ctx context.Context, appInfoID string, primary, secondary *string) error { + rels := Relationships{} + for name, id := range map[string]*string{"primaryCategory": primary, "secondaryCategory": secondary} { + switch { + case id == nil: + case *id == "": + rels[name] = Relationship{Data: json.RawMessage("null")} + default: + rels[name] = ToOne("appCategories", *id) + } + } + req := Resource[struct{}]{Type: "appInfos", ID: appInfoID, Relationships: rels} + return c.Patch(ctx, "/v1/appInfos/"+appInfoID, Document[Resource[struct{}]]{Data: req}, nil) +} diff --git a/internal/asc/screenshots.go b/internal/asc/screenshots.go new file mode 100644 index 0000000..ceb1099 --- /dev/null +++ b/internal/asc/screenshots.go @@ -0,0 +1,274 @@ +package asc + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Asset delivery states of an uploaded screenshot. +const ( + AssetStateAwaitingUpload = "AWAITING_UPLOAD" + AssetStateUploadComplete = "UPLOAD_COMPLETE" + AssetStateComplete = "COMPLETE" + AssetStateFailed = "FAILED" +) + +// AppScreenshotSet holds the screenshots of one display type (APP_IPHONE_67, +// APP_IPAD_PRO_3GEN_129, ...) in one version locale. +type AppScreenshotSet struct { + ID string + DisplayType string +} + +type appScreenshotSetAttributes struct { + ScreenshotDisplayType string `json:"screenshotDisplayType,omitempty"` +} + +// ListAppScreenshotSets lists the screenshot sets of a version locale. +func (c *Client) ListAppScreenshotSets(ctx context.Context, localizationID string) ([]AppScreenshotSet, error) { + rs, err := getAll[appScreenshotSetAttributes](ctx, c, "/v1/appStoreVersionLocalizations/"+localizationID+"/appScreenshotSets", nil) + if err != nil { + return nil, err + } + sets := make([]AppScreenshotSet, 0, len(rs)) + for _, r := range rs { + sets = append(sets, AppScreenshotSet{ID: r.ID, DisplayType: r.Attributes.ScreenshotDisplayType}) + } + return sets, nil +} + +// CreateAppScreenshotSet adds an empty set of a display type to a version locale. +func (c *Client) CreateAppScreenshotSet(ctx context.Context, localizationID, displayType string) (*AppScreenshotSet, error) { + req := Resource[appScreenshotSetAttributes]{ + Type: "appScreenshotSets", + Attributes: appScreenshotSetAttributes{ScreenshotDisplayType: displayType}, + Relationships: Relationships{"appStoreVersionLocalization": ToOne("appStoreVersionLocalizations", localizationID)}, + } + r, err := post[appScreenshotSetAttributes, appScreenshotSetAttributes](ctx, c, "/v1/appScreenshotSets", req) + if err != nil { + return nil, err + } + return &AppScreenshotSet{ID: r.ID, DisplayType: r.Attributes.ScreenshotDisplayType}, nil +} + +// AppScreenshot is one screenshot in a set. +type AppScreenshot struct { + ID string + FileName string + FileSize int64 + // Checksum is the MD5 (hex) of the uploaded file. + Checksum string + // TemplateURL has {w}, {h} and {f} placeholders; see ImageURL. + TemplateURL string + Width, Height int + State string + Errors []StateDetail + UploadOperations []UploadOperation +} + +// ImageURL is the download URL of the screenshot at its original size, in +// the format of its file name (png unless it was a JPEG). +func (s *AppScreenshot) ImageURL() string { + format := "png" + if ext := strings.ToLower(filepath.Ext(s.FileName)); ext == ".jpg" || ext == ".jpeg" { + format = "jpg" + } + return strings.NewReplacer("{w}", strconv.Itoa(s.Width), "{h}", strconv.Itoa(s.Height), "{f}", format).Replace(s.TemplateURL) +} + +type appScreenshotAttributes struct { + FileName string `json:"fileName,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + SourceFileChecksum string `json:"sourceFileChecksum,omitempty"` + ImageAsset *imageAsset `json:"imageAsset,omitempty"` + AssetDeliveryState *uploadState `json:"assetDeliveryState,omitempty"` + UploadOperations []UploadOperation `json:"uploadOperations,omitempty"` + Uploaded *bool `json:"uploaded,omitempty"` +} + +type imageAsset struct { + TemplateURL string `json:"templateUrl,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +func toAppScreenshot(r Resource[appScreenshotAttributes]) AppScreenshot { + a := r.Attributes + s := AppScreenshot{ID: r.ID, FileName: a.FileName, FileSize: a.FileSize, Checksum: a.SourceFileChecksum, UploadOperations: a.UploadOperations} + if a.ImageAsset != nil { + s.TemplateURL, s.Width, s.Height = a.ImageAsset.TemplateURL, a.ImageAsset.Width, a.ImageAsset.Height + } + if a.AssetDeliveryState != nil { + s.State, s.Errors = a.AssetDeliveryState.State, a.AssetDeliveryState.Errors + } + return s +} + +// ListAppScreenshots lists a set's screenshots in display order. +func (c *Client) ListAppScreenshots(ctx context.Context, setID string) ([]AppScreenshot, error) { + rs, err := getAll[appScreenshotAttributes](ctx, c, "/v1/appScreenshotSets/"+setID+"/appScreenshots", nil) + if err != nil { + return nil, err + } + shots := make([]AppScreenshot, 0, len(rs)) + for _, r := range rs { + shots = append(shots, toAppScreenshot(r)) + } + return shots, nil +} + +// GetAppScreenshot fetches one screenshot, e.g. to follow its delivery state. +func (c *Client) GetAppScreenshot(ctx context.Context, id string) (*AppScreenshot, error) { + r, err := getOne[appScreenshotAttributes](ctx, c, "/v1/appScreenshots/"+id, nil) + if err != nil { + return nil, err + } + s := toAppScreenshot(*r) + return &s, nil +} + +// CreateAppScreenshot reserves a screenshot at the end of the set and +// returns the upload operations for its bytes. +func (c *Client) CreateAppScreenshot(ctx context.Context, setID, fileName string, size int64) (*AppScreenshot, error) { + req := Resource[appScreenshotAttributes]{ + Type: "appScreenshots", + Attributes: appScreenshotAttributes{FileName: fileName, FileSize: size}, + Relationships: Relationships{"appScreenshotSet": ToOne("appScreenshotSets", setID)}, + } + r, err := post[appScreenshotAttributes, appScreenshotAttributes](ctx, c, "/v1/appScreenshots", req) + if err != nil { + return nil, err + } + s := toAppScreenshot(*r) + return &s, nil +} + +// CommitAppScreenshot marks the upload done; checksum is the file's MD5 in hex. +func (c *Client) CommitAppScreenshot(ctx context.Context, id, checksum string) error { + uploaded := true + req := Resource[appScreenshotAttributes]{Type: "appScreenshots", ID: id, Attributes: appScreenshotAttributes{Uploaded: &uploaded, SourceFileChecksum: checksum}} + return c.Patch(ctx, "/v1/appScreenshots/"+id, Document[Resource[appScreenshotAttributes]]{Data: req}, nil) +} + +// DeleteAppScreenshot removes a screenshot from its set. +func (c *Client) DeleteAppScreenshot(ctx context.Context, id string) error { + return c.Delete(ctx, "/v1/appScreenshots/"+id, nil) +} + +// FileMD5 returns the MD5 of a file in hex, the form sourceFileChecksum uses. +func FileMD5(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// UploadScreenshot appends the image at path to the set: reserve, PUT the +// bytes, commit with the MD5. Processing continues server-side; follow it +// with WaitForScreenshot. +func (c *Client) UploadScreenshot(ctx context.Context, setID, path string) (*AppScreenshot, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return nil, err + } + sum, err := FileMD5(path) + if err != nil { + return nil, err + } + shot, err := c.CreateAppScreenshot(ctx, setID, filepath.Base(path), st.Size()) + if err != nil { + return nil, fmt.Errorf("reserve screenshot %s: %w", filepath.Base(path), err) + } + if len(shot.UploadOperations) == 0 { + return nil, fmt.Errorf("App Store Connect returned no upload operations for %s", filepath.Base(path)) + } + if err := c.UploadChunks(ctx, f, shot.UploadOperations, nil); err != nil { + return nil, fmt.Errorf("upload screenshot %s: %w", filepath.Base(path), err) + } + if err := c.CommitAppScreenshot(ctx, shot.ID, sum); err != nil { + return nil, fmt.Errorf("commit screenshot %s: %w", filepath.Base(path), err) + } + shot.Checksum = sum + return shot, nil +} + +// ScreenshotFailedError reports a screenshot App Store Connect could not process. +type ScreenshotFailedError struct { + Screenshot *AppScreenshot +} + +func (e *ScreenshotFailedError) Error() string { + msgs := make([]string, 0, len(e.Screenshot.Errors)) + for _, d := range e.Screenshot.Errors { + msgs = append(msgs, d.String()) + } + reason := "no details" + if len(msgs) > 0 { + reason = strings.Join(msgs, "; ") + } + return fmt.Sprintf("App Store Connect rejected screenshot %s: %s", e.Screenshot.FileName, reason) +} + +// WaitForScreenshot polls until the screenshot's delivery is COMPLETE, +// returning a *ScreenshotFailedError when it FAILED. +func (c *Client) WaitForScreenshot(ctx context.Context, id string, interval time.Duration) (*AppScreenshot, error) { + p := c.newPoller(interval) + for { + s, err := c.GetAppScreenshot(ctx, id) + if err != nil { + return nil, err + } + switch s.State { + case AssetStateComplete: + return s, nil + case AssetStateFailed: + return s, &ScreenshotFailedError{Screenshot: s} + } + if err := p.wait(ctx); err != nil { + return s, err + } + } +} + +// Download GETs a public asset URL (a screenshot's ImageURL) into w. The +// URL is Apple's CDN, not the API, so no token is sent. +func (c *Client) Download(ctx context.Context, assetURL string, w io.Writer) error { + if assetURL == "" { + return errors.New("asset has no URL") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) + if err != nil { + return err + } + resp, err := c.upload.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: HTTP %d", assetURL, resp.StatusCode) + } + _, err = io.Copy(w, resp.Body) + return err +} diff --git a/internal/asc/screenshots_test.go b/internal/asc/screenshots_test.go new file mode 100644 index 0000000..eb65ca4 --- /dev/null +++ b/internal/asc/screenshots_test.go @@ -0,0 +1,190 @@ +package asc + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestUploadScreenshotFlow(t *testing.T) { + path, data := writeRandomFile(t, "01_home.png", 3_000) + sum := md5.Sum(data) + var ( + mu sync.Mutex + created map[string]any + commit map[string]any + put []byte + polls int + srv *httptest.Server + ) + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + switch { + case r.Method == "POST" && r.URL.Path == "/v1/appScreenshots": + _ = json.NewDecoder(r.Body).Decode(&created) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "appScreenshots", "id": "shot-1", "attributes": map[string]any{ + "fileName": "01_home.png", "fileSize": len(data), + "uploadOperations": []map[string]any{{"method": "PUT", "url": srv.URL + "/store", "offset": 0, "length": len(data)}}, + }}}) + case r.Method == "PUT" && r.URL.Path == "/store": + if r.Header.Get("Authorization") != "" { + t.Error("bearer token leaked to storage URL") + } + put, _ = io.ReadAll(r.Body) + w.WriteHeader(200) + case r.Method == "PATCH" && r.URL.Path == "/v1/appScreenshots/shot-1": + _ = json.NewDecoder(r.Body).Decode(&commit) + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "appScreenshots", "id": "shot-1"}}) + case r.Method == "GET" && r.URL.Path == "/v1/appScreenshots/shot-1": + polls++ + state := "UPLOAD_COMPLETE" + if polls >= 2 { + state = "COMPLETE" + } + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "appScreenshots", "id": "shot-1", "attributes": map[string]any{ + "fileName": "01_home.png", "assetDeliveryState": map[string]any{"state": state}, + }}}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + slept := recordSleeps(c) + ctx := context.Background() + + shot, err := c.UploadScreenshot(ctx, "set-1", path) + if err != nil { + t.Fatal(err) + } + if shot.ID != "shot-1" || shot.Checksum != hex.EncodeToString(sum[:]) { + t.Errorf("shot = %+v", shot) + } + done, err := c.WaitForScreenshot(ctx, shot.ID, time.Second) + if err != nil || done.State != AssetStateComplete || len(*slept) != 1 { + t.Errorf("wait: %+v %v slept %v", done, err, *slept) + } + + mu.Lock() + defer mu.Unlock() + attrs := obj(t, created, "data", "attributes") + if attrs["fileName"] != "01_home.png" || attrs["fileSize"] != float64(len(data)) { + t.Errorf("reserve attributes = %v", attrs) + } + if obj(t, created, "data", "relationships", "appScreenshotSet", "data")["id"] != "set-1" { + t.Errorf("reserve relationships = %v", created) + } + if !bytes.Equal(put, data) { + t.Error("uploaded bytes differ from the file") + } + commitAttrs := obj(t, commit, "data", "attributes") + if commitAttrs["uploaded"] != true || commitAttrs["sourceFileChecksum"] != hex.EncodeToString(sum[:]) { + t.Errorf("commit attributes = %v", commitAttrs) + } +} + +func TestWaitForScreenshotFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "appScreenshots", "id": "shot-1", "attributes": map[string]any{ + "fileName": "bad.png", "assetDeliveryState": map[string]any{"state": "FAILED", "errors": []map[string]string{{"code": "IMAGE_INCORRECT_DIMENSIONS", "description": "wrong size"}}}, + }}}) + })) + defer srv.Close() + _, err := newTestClient(t, srv).WaitForScreenshot(context.Background(), "shot-1", time.Millisecond) + var failed *ScreenshotFailedError + if !errors.As(err, &failed) || failed.Error() != "App Store Connect rejected screenshot bad.png: IMAGE_INCORRECT_DIMENSIONS: wrong size" { + t.Errorf("err = %v", err) + } +} + +func TestScreenshotImageURL(t *testing.T) { + s := AppScreenshot{FileName: "a.JPG", TemplateURL: "https://cdn.example/x/{w}x{h}bb.{f}", Width: 1290, Height: 2796} + if got := s.ImageURL(); got != "https://cdn.example/x/1290x2796bb.jpg" { + t.Errorf("ImageURL = %s", got) + } +} + +func TestLocalizationsAndCategories(t *testing.T) { + var bodies sync.Map + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + bodies.Store(r.Method+" "+r.URL.Path, body) + switch r.Method + " " + r.URL.Path { + case "GET /v1/appStoreVersions/v-1/appStoreVersionLocalizations": + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "appStoreVersionLocalizations", "id": "vl-1", "attributes": map[string]any{ + "locale": "en-US", "description": "Hello", "keywords": "a,b", "whatsNew": nil, + }}}}) + case "POST /v1/appInfoLocalizations": + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "appInfoLocalizations", "id": "il-2", "attributes": map[string]any{"locale": "de-DE", "name": "Hallo"}}}) + case "GET /v1/apps/app-1/appInfos": + if r.URL.Query().Get("include") != "primaryCategory,secondaryCategory" { + t.Errorf("appInfos query = %v", r.URL.Query()) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "appInfos", "id": "ai-1", "attributes": map[string]any{"state": "PREPARE_FOR_SUBMISSION"}, + "relationships": map[string]any{"primaryCategory": map[string]any{"data": map[string]any{"type": "appCategories", "id": "GAMES"}}, "secondaryCategory": map[string]any{"data": nil}}}}}) + case "PATCH /v1/appInfos/ai-1": + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "appInfos", "id": "ai-1"}}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + ctx := context.Background() + + locs, err := c.ListVersionLocalizations(ctx, "v-1") + if err != nil || len(locs) != 1 { + t.Fatalf("locs = %+v, %v", locs, err) + } + if l := locs[0]; l.Locale != "en-US" || l.Fields[AttrDescription] != "Hello" || l.Fields[AttrKeywords] != "a,b" { + t.Errorf("loc = %+v", l) + } + if _, has := locs[0].Fields[AttrWhatsNew]; has { + t.Error("null attribute must be absent") + } + + created, err := c.CreateAppInfoLocalization(ctx, "ai-1", "de-DE", map[string]string{AttrName: "Hallo"}) + if err != nil || created.ID != "il-2" { + t.Fatalf("created = %+v, %v", created, err) + } + body, _ := bodies.Load("POST /v1/appInfoLocalizations") + if a := obj(t, body, "data", "attributes"); a["locale"] != "de-DE" || a["name"] != "Hallo" { + t.Errorf("create attributes = %v", a) + } + if obj(t, body, "data", "relationships", "appInfo", "data")["id"] != "ai-1" { + t.Errorf("create relationships = %v", body) + } + + infos, err := c.ListAppInfos(ctx, "app-1") + if err != nil || len(infos) != 1 || infos[0].PrimaryCategoryID != "GAMES" || infos[0].SecondaryCategoryID != "" || !infos[0].Editable() { + t.Fatalf("infos = %+v, %v", infos, err) + } + primary, clear := "PRODUCTIVITY", "" + if err := c.UpdateAppInfoCategories(ctx, "ai-1", &primary, &clear); err != nil { + t.Fatal(err) + } + body, _ = bodies.Load("PATCH /v1/appInfos/ai-1") + rels := obj(t, body, "data", "relationships") + if obj(t, rels, "primaryCategory", "data")["id"] != "PRODUCTIVITY" { + t.Errorf("primary = %v", rels) + } + if data, has := obj(t, rels, "secondaryCategory")["data"]; !has || data != nil { + t.Errorf("secondary must be sent as null: %v", rels) + } + if _, has := obj(t, body, "data")["attributes"]; has { + t.Error("category update must not send attributes") + } +} diff --git a/internal/asc/versions.go b/internal/asc/versions.go index 63918a8..ed358d3 100644 --- a/internal/asc/versions.go +++ b/internal/asc/versions.go @@ -42,6 +42,20 @@ type appStoreVersionAttributes struct { CreatedDate *time.Time `json:"createdDate,omitempty"` } +// Editable reports whether the version's metadata can be changed: it is being +// prepared, or came back from review. +func (v *AppStoreVersion) Editable() bool { + state := v.State + if state == "" { + state = v.AppStoreState + } + switch state { + case "PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED", "INVALID_BINARY": + return true + } + return false +} + func toAppStoreVersion(r Resource[appStoreVersionAttributes]) AppStoreVersion { v := AppStoreVersion{ ID: r.ID, From 6c99992162b2716b964720f41ffe25de241dfd04 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:43:52 +0200 Subject: [PATCH 2/5] metadata: pull and push App Store metadata in the fastlane deliver layout Pull writes metadata//.txt, the category files and, on request, screenshots///. NewPlan validates lengths, URLs, categories and screenshot sizes locally, then diffs against the editable version and app info; Apply writes only the differences, creating a missing version and replanning against the localizations App Store Connect copied into it. --- internal/metadata/fake_test.go | 276 +++++++++++++++++ internal/metadata/fields.go | 212 +++++++++++++ internal/metadata/metadata.go | 165 ++++++++++ internal/metadata/metadata_test.go | 466 ++++++++++++++++++++++++++++ internal/metadata/pull.go | 239 +++++++++++++++ internal/metadata/push.go | 475 +++++++++++++++++++++++++++++ internal/metadata/screenshots.go | 178 +++++++++++ 7 files changed, 2011 insertions(+) create mode 100644 internal/metadata/fake_test.go create mode 100644 internal/metadata/fields.go create mode 100644 internal/metadata/metadata.go create mode 100644 internal/metadata/metadata_test.go create mode 100644 internal/metadata/pull.go create mode 100644 internal/metadata/push.go create mode 100644 internal/metadata/screenshots.go diff --git a/internal/metadata/fake_test.go b/internal/metadata/fake_test.go new file mode 100644 index 0000000..ee255a7 --- /dev/null +++ b/internal/metadata/fake_test.go @@ -0,0 +1,276 @@ +package metadata + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/md5" + "crypto/rand" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// fakeASC is a small stateful App Store Connect: resources live in one list, +// keyed by type, ID and parent, and the generic JSON:API routes +// (GET /v1///, POST /v1/, PATCH and DELETE +// /v1//) act on it. Screenshot bytes go to /upload/ and come +// back from /cdn/. +type fakeASC struct { + t *testing.T + mu sync.Mutex + srv *httptest.Server + items []*fakeRes + seq int + calls []string + blobs map[string][]byte + bodies map[string]map[string]any +} + +type fakeRes struct { + typ, id, parent string + attrs map[string]any + rels map[string]any +} + +func newFakeASC(t *testing.T) *fakeASC { + f := &fakeASC{t: t, blobs: map[string][]byte{}, bodies: map[string]map[string]any{}} + f.srv = httptest.NewServer(http.HandlerFunc(f.handle)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeASC) client() *asc.Client { + f.t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + f.t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + c, err := asc.NewClient(creds, asc.WithBaseURL(f.srv.URL), asc.WithRetryDelay(time.Millisecond)) + if err != nil { + f.t.Fatal(err) + } + return c +} + +// add stores a resource; an empty id gets a generated one. +func (f *fakeASC) add(typ, id, parent string, attrs map[string]any) *fakeRes { + if id == "" { + f.seq++ + id = fmt.Sprintf("%s-%d", typ, f.seq) + } + if attrs == nil { + attrs = map[string]any{} + } + r := &fakeRes{typ: typ, id: id, parent: parent, attrs: attrs, rels: map[string]any{}} + f.items = append(f.items, r) + return r +} + +func (f *fakeASC) find(typ, id string) *fakeRes { + for _, r := range f.items { + if r.typ == typ && r.id == id { + return r + } + } + return nil +} + +func (f *fakeASC) children(typ, parent string) []*fakeRes { + var out []*fakeRes + for _, r := range f.items { + if r.typ == typ && r.parent == parent { + out = append(out, r) + } + } + return out +} + +func (f *fakeASC) json(r *fakeRes) map[string]any { + return map[string]any{"type": r.typ, "id": r.id, "attributes": r.attrs, "relationships": r.rels} +} + +// seed is an app with an editable version 1.1 in en-US and its app info. +func (f *fakeASC) seed() { + f.add("apps", "app-1", "", map[string]any{"bundleId": "com.example.app", "name": "My App"}) + f.add("appStoreVersions", "v-1", "app-1", map[string]any{"versionString": "1.1", "appVersionState": "PREPARE_FOR_SUBMISSION", "platform": "IOS", "createdDate": "2026-09-01T10:00:00Z"}) + info := f.add("appInfos", "ai-1", "app-1", map[string]any{"state": "PREPARE_FOR_SUBMISSION"}) + info.rels["primaryCategory"] = map[string]any{"data": map[string]any{"type": "appCategories", "id": "GAMES"}} + f.add("appInfoLocalizations", "il-en", "ai-1", map[string]any{"locale": "en-US", "name": "My App", "subtitle": "Old subtitle", "privacyPolicyUrl": "https://example.com/privacy"}) + f.add("appStoreVersionLocalizations", "vl-en", "v-1", map[string]any{"locale": "en-US", "description": "Old description", "keywords": "a,b", "whatsNew": nil, "supportUrl": "https://example.com/support"}) +} + +// writes lists the non-GET API calls in order. +func (f *fakeASC) writes() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.calls { + if !strings.HasPrefix(c, "GET ") && !strings.HasPrefix(c, "PUT /upload") { + out = append(out, c) + } + } + return out +} + +func (f *fakeASC) body(call string) map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.bodies[call] +} + +func (f *fakeASC) write(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (f *fakeASC) handle(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + call := r.Method + " " + r.URL.Path + f.calls = append(f.calls, call) + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + var body map[string]any + if r.Method == "POST" || r.Method == "PATCH" { + _ = json.NewDecoder(r.Body).Decode(&body) + f.bodies[call] = body + } + switch { + case r.Method == "PUT" && parts[0] == "upload": + data, _ := io.ReadAll(r.Body) + f.blobs[parts[1]] = data + w.WriteHeader(200) + case r.Method == "GET" && parts[0] == "cdn": + _, _ = w.Write(f.blobs[strings.SplitN(parts[1], ".", 2)[0]]) + case r.Method == "GET" && len(parts) == 2 && parts[1] == "apps": + var list []any + for _, a := range f.children("apps", "") { + if a.attrs["bundleId"] == r.URL.Query().Get("filter[bundleId]") { + list = append(list, f.json(a)) + } + } + f.write(w, 200, map[string]any{"data": list}) + case r.Method == "GET" && len(parts) == 4: + list := []any{} + for _, c := range f.children(parts[3], parts[2]) { + list = append(list, f.json(c)) + } + f.write(w, 200, map[string]any{"data": list}) + case r.Method == "GET" && len(parts) == 3: + res := f.find(parts[1], parts[2]) + if res == nil { + w.WriteHeader(404) + return + } + f.write(w, 200, map[string]any{"data": f.json(res)}) + case r.Method == "POST" && len(parts) == 2: + f.create(w, parts[1], body) + case r.Method == "PATCH" && len(parts) == 3: + res := f.find(parts[1], parts[2]) + if res == nil { + w.WriteHeader(404) + return + } + data, _ := body["data"].(map[string]any) + attrs, _ := data["attributes"].(map[string]any) + for k, v := range attrs { + res.attrs[k] = v + } + if rels, ok := data["relationships"].(map[string]any); ok { + for k, v := range rels { + res.rels[k] = v + } + } + if res.typ == "appScreenshots" && attrs["uploaded"] == true { + res.attrs["assetDeliveryState"] = map[string]any{"state": "COMPLETE"} + res.attrs["imageAsset"] = map[string]any{"templateUrl": f.srv.URL + "/cdn/" + res.id + ".{f}", "width": 1290, "height": 2796} + delete(res.attrs, "uploaded") + } + f.write(w, 200, map[string]any{"data": f.json(res)}) + case r.Method == "DELETE" && len(parts) == 3: + for i, res := range f.items { + if res.typ == parts[1] && res.id == parts[2] { + f.items = append(f.items[:i], f.items[i+1:]...) + w.WriteHeader(204) + return + } + } + w.WriteHeader(404) + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } +} + +func (f *fakeASC) create(w http.ResponseWriter, typ string, body map[string]any) { + data, _ := body["data"].(map[string]any) + attrs, _ := data["attributes"].(map[string]any) + parent := "" + rels, _ := data["relationships"].(map[string]any) + for _, rel := range rels { + linkage, _ := rel.(map[string]any)["data"].(map[string]any) + parent, _ = linkage["id"].(string) + } + res := f.add(typ, "", parent, attrs) + switch typ { + case "appStoreVersions": + // App Store Connect copies the newest version's localizations + // (without What's New) and opens an editable app info. + res.attrs["appVersionState"] = "PREPARE_FOR_SUBMISSION" + res.attrs["createdDate"] = "2026-10-01T10:00:00Z" + var newest *fakeRes + for _, v := range f.children("appStoreVersions", parent) { + if v != res && (newest == nil || fmt.Sprint(v.attrs["createdDate"]) > fmt.Sprint(newest.attrs["createdDate"])) { + newest = v + } + } + if newest != nil { + for _, l := range f.children("appStoreVersionLocalizations", newest.id) { + copied := map[string]any{} + for k, v := range l.attrs { + copied[k] = v + } + delete(copied, "whatsNew") + f.add("appStoreVersionLocalizations", "", res.id, copied) + } + } + for _, info := range f.children("appInfos", parent) { + if info.attrs["state"] == "READY_FOR_DISTRIBUTION" { + editable := f.add("appInfos", "", parent, map[string]any{"state": "PREPARE_FOR_SUBMISSION"}) + editable.rels = info.rels + for _, l := range f.children("appInfoLocalizations", info.id) { + f.add("appInfoLocalizations", "", editable.id, l.attrs) + } + } + } + case "appScreenshots": + res.attrs["assetDeliveryState"] = map[string]any{"state": "AWAITING_UPLOAD"} + res.attrs["uploadOperations"] = []map[string]any{{"method": "PUT", "url": f.srv.URL + "/upload/" + res.id, "offset": 0, "length": res.attrs["fileSize"]}} + } + f.write(w, 201, map[string]any{"data": f.json(res)}) +} + +// addScreenshot puts a processed screenshot with the given bytes into a set. +func (f *fakeASC) addScreenshot(setID, fileName string, data []byte) { + sum := md5.Sum(data) + res := f.add("appScreenshots", "", setID, map[string]any{"fileName": fileName, "fileSize": len(data), "sourceFileChecksum": hex.EncodeToString(sum[:]), + "assetDeliveryState": map[string]any{"state": "COMPLETE"}}) + res.attrs["imageAsset"] = map[string]any{"templateUrl": f.srv.URL + "/cdn/" + res.id + ".{f}", "width": 1290, "height": 2796} + f.blobs[res.id] = data +} + +func ctx() context.Context { return context.Background() } diff --git a/internal/metadata/fields.go b/internal/metadata/fields.go new file mode 100644 index 0000000..12406a3 --- /dev/null +++ b/internal/metadata/fields.go @@ -0,0 +1,212 @@ +package metadata + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// Field is one localized text field and its fastlane deliver file name. +type Field struct { + // File is the name under metadata// without ".txt". + File string + // Attr is the App Store Connect attribute. + Attr string + // AppInfo: an appInfoLocalizations field rather than an + // appStoreVersionLocalizations one. + AppInfo bool + // Limit is the maximum length in characters; 0 for none. + Limit int + URL bool +} + +// Fields lists every localized field, app info first (the order writes happen in). +var Fields = []Field{ + {File: "name", Attr: asc.AttrName, AppInfo: true, Limit: 30}, + {File: "subtitle", Attr: asc.AttrSubtitle, AppInfo: true, Limit: 30}, + {File: "privacy_url", Attr: asc.AttrPrivacyPolicyURL, AppInfo: true, URL: true}, + {File: "description", Attr: asc.AttrDescription, Limit: 4000}, + {File: "keywords", Attr: asc.AttrKeywords, Limit: 100}, + {File: "release_notes", Attr: asc.AttrWhatsNew, Limit: 4000}, + {File: "promotional_text", Attr: asc.AttrPromotionalText, Limit: 170}, + {File: "marketing_url", Attr: asc.AttrMarketingURL, URL: true}, + {File: "support_url", Attr: asc.AttrSupportURL, URL: true}, +} + +// Category files at the top of the metadata directory. +const ( + PrimaryCategoryFile = "primary_category" + SecondaryCategoryFile = "secondary_category" +) + +// Categories are App Store Connect's appCategories IDs. +var Categories = []string{ + "BOOKS", "BUSINESS", "DEVELOPER_TOOLS", "EDUCATION", "ENTERTAINMENT", "FINANCE", "FOOD_AND_DRINK", + "GAMES", "GRAPHICS_AND_DESIGN", "HEALTH_AND_FITNESS", "LIFESTYLE", "MAGAZINES_AND_NEWSPAPERS", + "MEDICAL", "MUSIC", "NAVIGATION", "NEWS", "PHOTO_AND_VIDEO", "PRODUCTIVITY", "REFERENCE", + "SHOPPING", "SOCIAL_NETWORKING", "SPORTS", "STICKERS", "TRAVEL", "UTILITIES", "WEATHER", +} + +// Local is the metadata directory as read from disk. Absent files are +// absent from the maps: push leaves those fields alone. +type Local struct { + // Locales maps locale → Field.File → value. + Locales map[string]map[string]string + // Primary and Secondary are the category files, nil when absent. + Primary, Secondary *string +} + +// skippedDirs are fastlane deliver directories that are not locales. +var skippedDirs = map[string]bool{"default": true, "review_information": true, "trade_representative_contact_information": true} + +// normalize makes file and App Store Connect text comparable: LF line +// endings, no surrounding whitespace. +func normalize(s string) string { + return strings.TrimSpace(strings.ReplaceAll(s, "\r\n", "\n")) +} + +func readText(path string) (*string, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + s := normalize(string(data)) + return &s, nil +} + +// LoadLocal reads the metadata directory. A missing directory is empty. +func LoadLocal(dir string) (*Local, error) { + l := &Local{Locales: map[string]map[string]string{}} + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return l, nil + } + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() || skippedDirs[e.Name()] || strings.HasPrefix(e.Name(), ".") { + continue + } + values := map[string]string{} + for _, f := range Fields { + v, err := readText(filepath.Join(dir, e.Name(), f.File+".txt")) + if err != nil { + return nil, err + } + if v != nil { + values[f.File] = *v + } + } + if len(values) > 0 { + l.Locales[e.Name()] = values + } + } + if l.Primary, err = readCategory(filepath.Join(dir, PrimaryCategoryFile+".txt")); err != nil { + return nil, err + } + if l.Secondary, err = readCategory(filepath.Join(dir, SecondaryCategoryFile+".txt")); err != nil { + return nil, err + } + return l, nil +} + +func readCategory(path string) (*string, error) { + v, err := readText(path) + if v == nil || err != nil { + return v, err + } + c := NormalizeCategory(*v) + return &c, nil +} + +// NormalizeCategory accepts App Store Connect IDs (PHOTO_AND_VIDEO) and the +// legacy names older fastlane setups wrote (MZGenre.SocialNetworking). +func NormalizeCategory(s string) string { + s = strings.TrimSpace(s) + if rest, ok := strings.CutPrefix(s, "MZGenre."); ok { + var b strings.Builder + for i, r := range rest { + if i > 0 && unicode.IsUpper(r) { + b.WriteByte('_') + } + b.WriteRune(r) + } + s = b.String() + } + return strings.ToUpper(s) +} + +// LocaleNames returns the locales in a stable order. +func (l *Local) LocaleNames() []string { + names := make([]string, 0, len(l.Locales)) + for name := range l.Locales { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Validate checks every value against App Store Connect's limits so a push +// fails before its first write. All problems are reported together. +func (l *Local) Validate() error { + var problems []string + for _, locale := range l.LocaleNames() { + values := l.Locales[locale] + for _, f := range Fields { + v, ok := values[f.File] + if !ok { + continue + } + if n := utf8.RuneCountInString(v); f.Limit > 0 && n > f.Limit { + problems = append(problems, fmt.Sprintf("%s/%s.txt is %d characters, the limit is %d", locale, f.File, n, f.Limit)) + } + if f.URL && v != "" { + if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + problems = append(problems, fmt.Sprintf("%s/%s.txt is not an http(s) URL: %q", locale, f.File, v)) + } + } + } + } + for _, c := range []struct { + file string + value *string + }{{PrimaryCategoryFile, l.Primary}, {SecondaryCategoryFile, l.Secondary}} { + if c.value == nil { + continue + } + if *c.value == "" { + if c.file == PrimaryCategoryFile { + problems = append(problems, PrimaryCategoryFile+".txt is empty; the primary category cannot be removed") + } + continue + } + if !knownCategory(*c.value) { + problems = append(problems, fmt.Sprintf("%s.txt: unknown category %s (one of %s)", c.file, *c.value, strings.Join(Categories, ", "))) + } + } + if len(problems) > 0 { + return fmt.Errorf("metadata is not valid:\n %s", strings.Join(problems, "\n ")) + } + return nil +} + +func knownCategory(id string) bool { + for _, c := range Categories { + if c == id { + return true + } + } + return false +} diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go new file mode 100644 index 0000000..a80442e --- /dev/null +++ b/internal/metadata/metadata.go @@ -0,0 +1,165 @@ +// Package metadata syncs an app's App Store listing with files laid out like +// fastlane deliver's: metadata//.txt, metadata/*_category.txt +// and screenshots//. Pull writes what App Store Connect has; Plan +// diffs the files against it and Apply writes the differences. +package metadata + +import ( + "context" + "fmt" + "io" + "sort" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// Options configures Pull and Plan. +type Options struct { + BundleID string + // Version is the App Store version (marketing version) to work on. Empty + // picks the editable one; pull falls back to the newest. Plan creates a + // version that does not exist yet. + Version string + // MetadataDir defaults to "metadata", ScreenshotsDir to "screenshots". + MetadataDir string + ScreenshotsDir string + // Screenshots includes screenshots (download on pull, upload on push). + Screenshots bool + // ReplaceScreenshots (push) deletes a set's screenshots and uploads the + // local ones whenever the two differ; without it only missing ones are added. + ReplaceScreenshots bool + // Clean (pull) removes local field files and, with Screenshots, images + // App Store Connect does not have. + Clean bool + // PollInterval spaces screenshot processing polls (default 2s). + PollInterval time.Duration + Log io.Writer +} + +func (o *Options) metadataDir() string { + if o.MetadataDir == "" { + return "metadata" + } + return o.MetadataDir +} + +func (o *Options) screenshotsDir() string { + if o.ScreenshotsDir == "" { + return "screenshots" + } + return o.ScreenshotsDir +} + +func (o *Options) pollInterval() time.Duration { + if o.PollInterval <= 0 { + return 2 * time.Second + } + return o.PollInterval +} + +// AppRef identifies the app in results. +type AppRef struct { + ID string `json:"id"` + Name string `json:"name"` + BundleID string `json:"bundle_id"` +} + +// VersionRef describes the App Store version worked on. +type VersionRef struct { + ID string `json:"id,omitempty"` + VersionString string `json:"version_string"` + State string `json:"state,omitempty"` + Created bool `json:"created,omitempty"` +} + +func versionRef(v *asc.AppStoreVersion) VersionRef { + state := v.State + if state == "" { + state = v.AppStoreState + } + return VersionRef{ID: v.ID, VersionString: v.VersionString, State: state} +} + +func logf(w io.Writer, format string, args ...any) { + if w != nil { + fmt.Fprintf(w, format+"\n", args...) + } +} + +// target is the App Store Connect side of a sync. +type target struct { + app *asc.App + // version is nil when Options.Version does not exist yet (push creates it). + version *asc.AppStoreVersion + // base is what a version that does not exist yet is compared against: + // the newest version, whose localizations App Store Connect copies. + base *asc.AppStoreVersion + info *asc.AppInfo +} + +// resolveTarget finds the version and app info to work on. push requires +// them to be editable; pull reads whatever is newest. +func resolveTarget(ctx context.Context, client *asc.Client, app *asc.App, version string, push bool) (*target, error) { + versions, err := client.ListAppStoreVersions(ctx, app.ID, asc.PlatformIOS, "") + if err != nil { + return nil, err + } + sort.SliceStable(versions, func(i, j int) bool { return versions[i].CreatedDate.After(versions[j].CreatedDate) }) + t := &target{app: app} + if len(versions) > 0 { + t.base = &versions[0] + } + switch { + case version != "": + for i := range versions { + if versions[i].VersionString == version { + t.version = &versions[i] + } + } + if t.version == nil && !push { + return nil, fmt.Errorf("%s has no App Store version %s", app.Name, version) + } + if t.version != nil && push && !t.version.Editable() { + return nil, fmt.Errorf("version %s is %s and cannot be edited; pass --version with a new version number to create one", version, versionRef(t.version).State) + } + default: + for i := range versions { + if versions[i].Editable() { + t.version = &versions[i] + break + } + } + if t.version == nil { + if push || t.base == nil { + return nil, fmt.Errorf("%s has no App Store version being prepared; pass --version X.Y to create one", app.Name) + } + t.version = t.base + } + } + + infos, err := client.ListAppInfos(ctx, app.ID) + if err != nil { + return nil, err + } + if len(infos) == 0 { + return nil, fmt.Errorf("%s has no app info record", app.Name) + } + t.info = &infos[0] + for i := range infos { + if infos[i].Editable() { + t.info = &infos[i] + break + } + } + return t, nil +} + +// byLocale indexes localizations by locale. +func byLocale(locs []asc.Localization) map[string]asc.Localization { + m := make(map[string]asc.Localization, len(locs)) + for _, l := range locs { + m[l.Locale] = l + } + return m +} diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go new file mode 100644 index 0000000..068d176 --- /dev/null +++ b/internal/metadata/metadata_test.go @@ -0,0 +1,466 @@ +package metadata + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// pngBytes is a w×h PNG; seed varies one pixel so checksums differ. +func pngBytes(t *testing.T, w, h int, seed uint8) []byte { + t.Helper() + img := image.NewGray(image.Rect(0, 0, w, h)) + img.SetGray(0, 0, color.Gray{Y: seed}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func changeLines(p *Plan) []string { + lines := make([]string, 0, len(p.Changes)) + for i := range p.Changes { + lines = append(lines, p.Changes[i].String()) + } + return lines +} + +func TestValidateRejectsBeforeAnyRequest(t *testing.T) { + f := newFakeASC(t) + f.seed() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "en-US", "keywords.txt"), strings.Repeat("k", 101)) + writeFile(t, filepath.Join(dir, "en-US", "subtitle.txt"), "This subtitle is far too long for the store") + writeFile(t, filepath.Join(dir, "en-US", "name.txt"), "Ünïcödé counts as characters") // 28 runes, more bytes + writeFile(t, filepath.Join(dir, "en-US", "support_url.txt"), "example.com/support") + writeFile(t, filepath.Join(dir, "en-US", "promotional_text.txt"), strings.Repeat("p", 171)) + writeFile(t, filepath.Join(dir, "primary_category.txt"), "GAMEZ") + + _, err := NewPlan(ctx(), f.client(), &Options{BundleID: "com.example.app", MetadataDir: dir}) + if err == nil { + t.Fatal("expected a validation error") + } + for _, want := range []string{"en-US/keywords.txt is 101 characters, the limit is 100", "en-US/subtitle.txt is 43 characters, the limit is 30", "en-US/promotional_text.txt is 171", "en-US/support_url.txt is not an http(s) URL", "unknown category GAMEZ"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error lacks %q:\n%v", want, err) + } + } + if strings.Contains(err.Error(), "name.txt") { + t.Errorf("name is within the limit in characters: %v", err) + } + if len(f.calls) != 0 { + t.Errorf("requests before validation passed: %v", f.calls) + } +} + +func TestPlanDiffsAndApplyWritesOnlyChanges(t *testing.T) { + f := newFakeASC(t) + f.seed() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "en-US", "name.txt"), "My App\n") + writeFile(t, filepath.Join(dir, "en-US", "subtitle.txt"), "New subtitle\n") + writeFile(t, filepath.Join(dir, "en-US", "description.txt"), "New description\r\nline two\n") + writeFile(t, filepath.Join(dir, "en-US", "keywords.txt"), "a,b") + writeFile(t, filepath.Join(dir, "de-DE", "name.txt"), "Meine App") + writeFile(t, filepath.Join(dir, "de-DE", "description.txt"), "Beschreibung") + writeFile(t, filepath.Join(dir, "primary_category.txt"), "MZGenre.Productivity") + writeFile(t, filepath.Join(dir, "secondary_category.txt"), "") + writeFile(t, filepath.Join(dir, "review_information", "notes.txt"), "ignored") + + opts := &Options{BundleID: "com.example.app", MetadataDir: dir} + plan, err := NewPlan(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + if w := f.writes(); len(w) != 0 { + t.Fatalf("planning wrote: %v", w) + } + want := []string{ + "Will add locale de-DE to the app info (name, subtitle, privacy URL)", + `Will update de-DE name: (empty) → "Meine App"`, + `Will update en-US subtitle: "Old subtitle" → "New subtitle"`, + "Will add locale de-DE to the version", + "Will update de-DE description (0 → 12 characters)", + "Will update en-US description (15 → 24 characters)", + `Will set primary category: "GAMES" → "PRODUCTIVITY"`, + } + if got := changeLines(plan); !reflect.DeepEqual(got, want) { + t.Errorf("plan:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if plan.Version.VersionString != "1.1" || plan.Version.ID != "v-1" { + t.Errorf("version = %+v", plan.Version) + } + + if err := plan.Apply(ctx()); err != nil { + t.Fatal(err) + } + wantWrites := []string{ + "POST /v1/appInfoLocalizations", + "PATCH /v1/appInfoLocalizations/il-en", + "POST /v1/appStoreVersionLocalizations", + "PATCH /v1/appStoreVersionLocalizations/vl-en", + "PATCH /v1/appInfos/ai-1", + } + if got := f.writes(); !reflect.DeepEqual(got, wantWrites) { + t.Errorf("writes = %v", got) + } + if a := obj(t, f.body("PATCH /v1/appInfoLocalizations/il-en"), "data", "attributes"); !reflect.DeepEqual(a, map[string]any{"subtitle": "New subtitle"}) { + t.Errorf("app info patch = %v", a) + } + if a := obj(t, f.body("PATCH /v1/appStoreVersionLocalizations/vl-en"), "data", "attributes"); !reflect.DeepEqual(a, map[string]any{"description": "New description\nline two"}) { + t.Errorf("version patch = %v", a) + } + created := f.body("POST /v1/appStoreVersionLocalizations") + if a := obj(t, created, "data", "attributes"); a["locale"] != "de-DE" || a["description"] != "Beschreibung" { + t.Errorf("new locale = %v", a) + } + if obj(t, created, "data", "relationships", "appStoreVersion", "data")["id"] != "v-1" { + t.Errorf("new locale relationships = %v", created) + } + if obj(t, f.body("PATCH /v1/appInfos/ai-1"), "data", "relationships", "primaryCategory", "data")["id"] != "PRODUCTIVITY" { + t.Errorf("category patch = %v", f.body("PATCH /v1/appInfos/ai-1")) + } + // secondary_category.txt is empty and App Store Connect has none: no change. + if _, has := obj(t, f.body("PATCH /v1/appInfos/ai-1"), "data", "relationships")["secondaryCategory"]; has { + t.Error("secondary category must not be sent") + } + + again, err := NewPlan(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + if !again.Empty() { + t.Errorf("second plan is not empty: %v", changeLines(again)) + } +} + +func TestPlanNeedsNameForNewAppInfoLocale(t *testing.T) { + f := newFakeASC(t) + f.seed() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "fr-FR", "subtitle.txt"), "Sous-titre") + _, err := NewPlan(ctx(), f.client(), &Options{BundleID: "com.example.app", MetadataDir: dir}) + if err == nil || !strings.Contains(err.Error(), "fr-FR/name.txt") { + t.Errorf("err = %v", err) + } +} + +func TestPushCreatesVersionAndReplans(t *testing.T) { + f := newFakeASC(t) + f.seed() + f.find("appStoreVersions", "v-1").attrs["appVersionState"] = "READY_FOR_DISTRIBUTION" + f.find("appInfos", "ai-1").attrs["state"] = "READY_FOR_DISTRIBUTION" + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "en-US", "description.txt"), "Old description") + writeFile(t, filepath.Join(dir, "en-US", "release_notes.txt"), "Bug fixes") + writeFile(t, filepath.Join(dir, "en-US", "subtitle.txt"), "New subtitle") + + _, err := NewPlan(ctx(), f.client(), &Options{BundleID: "com.example.app", MetadataDir: dir}) + if err == nil || !strings.Contains(err.Error(), "--version") { + t.Fatalf("without --version: %v", err) + } + plan, err := NewPlan(ctx(), f.client(), &Options{BundleID: "com.example.app", MetadataDir: dir, Version: "1.2"}) + if err != nil { + t.Fatal(err) + } + want := []string{ + "Will create App Store version 1.2", + `Will update en-US subtitle: "Old subtitle" → "New subtitle"`, + "Will update en-US release_notes (0 → 9 characters)", + } + if got := changeLines(plan); !reflect.DeepEqual(got, want) { + t.Errorf("plan = %q", got) + } + if err := plan.Apply(ctx()); err != nil { + t.Fatal(err) + } + if !plan.Version.Created || plan.Version.VersionString != "1.2" || plan.Version.ID == "v-1" { + t.Errorf("version = %+v", plan.Version) + } + writes := f.writes() + if len(writes) != 3 || writes[0] != "POST /v1/appStoreVersions" || !strings.HasPrefix(writes[1], "PATCH /v1/appInfoLocalizations/") || !strings.HasPrefix(writes[2], "PATCH /v1/appStoreVersionLocalizations/") { + t.Fatalf("writes = %v", writes) + } + // The copied localization is patched, not the old version's. + if writes[2] == "PATCH /v1/appStoreVersionLocalizations/vl-en" || writes[1] == "PATCH /v1/appInfoLocalizations/il-en" { + t.Errorf("patched the live version: %v", writes) + } + if a := obj(t, f.body(writes[2]), "data", "attributes"); !reflect.DeepEqual(a, map[string]any{"whatsNew": "Bug fixes"}) { + t.Errorf("version patch = %v", a) + } +} + +func TestPushRefusesLockedVersion(t *testing.T) { + f := newFakeASC(t) + f.seed() + f.find("appStoreVersions", "v-1").attrs["appVersionState"] = "WAITING_FOR_REVIEW" + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "en-US", "keywords.txt"), "x") + _, err := NewPlan(ctx(), f.client(), &Options{BundleID: "com.example.app", MetadataDir: dir, Version: "1.1"}) + if err == nil || !strings.Contains(err.Error(), "WAITING_FOR_REVIEW") { + t.Errorf("err = %v", err) + } +} + +func TestPushScreenshots(t *testing.T) { + f := newFakeASC(t) + f.seed() + metaDir, shotDir := t.TempDir(), t.TempDir() + first, second := pngBytes(t, 1290, 2796, 1), pngBytes(t, 2796, 1290, 2) // landscape still APP_IPHONE_67 + writeFile(t, filepath.Join(shotDir, "en-US", "01_home.png"), string(first)) + writeFile(t, filepath.Join(shotDir, "en-US", "02_list.png"), string(second)) + writeFile(t, filepath.Join(shotDir, "en-US", "APP_IPAD_PRO_129", "ipad.png"), string(pngBytes(t, 2048, 2732, 3))) + writeFile(t, filepath.Join(shotDir, "en-US", "iMessage", "x.png"), string(first)) + writeFile(t, filepath.Join(shotDir, "ja", "home.png"), string(first)) + writeFile(t, filepath.Join(metaDir, "ja", "name.txt"), "マイアプリ") + + opts := &Options{BundleID: "com.example.app", MetadataDir: metaDir, ScreenshotsDir: shotDir, Screenshots: true, PollInterval: 1} + plan, err := NewPlan(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + want := []string{ + "Will add locale ja to the app info (name, subtitle, privacy URL)", + `Will update ja name: (empty) → "マイアプリ"`, + "Will upload 1 screenshot in en-US APP_IPAD_PRO_129", + "Will upload 2 screenshots in en-US APP_IPHONE_67", + "Will upload 1 screenshot in ja APP_IPHONE_67", + } + if got := changeLines(plan); !reflect.DeepEqual(got, want) { + t.Errorf("plan:\n%s", strings.Join(got, "\n")) + } + if len(plan.Warnings) != 1 || !strings.Contains(plan.Warnings[0], "iMessage") { + t.Errorf("warnings = %v", plan.Warnings) + } + if err := plan.Apply(ctx()); err != nil { + t.Fatal(err) + } + f.mu.Lock() + var sets []string + for _, r := range f.items { + if r.typ == "appScreenshotSets" { + sets = append(sets, r.parent+" "+fmt.Sprint(r.attrs["screenshotDisplayType"])) + } + } + jaLoc := "" + for _, r := range f.items { + if r.typ == "appStoreVersionLocalizations" && r.attrs["locale"] == "ja" { + jaLoc = r.id + } + } + iphoneSet := "" + for _, r := range f.items { + if r.typ == "appScreenshotSets" && r.parent == "vl-en" && r.attrs["screenshotDisplayType"] == "APP_IPHONE_67" { + iphoneSet = r.id + } + } + shots := f.children("appScreenshots", iphoneSet) + f.mu.Unlock() + if jaLoc == "" { + t.Fatal("ja was not added to the version for its screenshots") + } + if !reflect.DeepEqual(sets, []string{"vl-en APP_IPAD_PRO_129", "vl-en APP_IPHONE_67", jaLoc + " APP_IPHONE_67"}) { + t.Errorf("sets = %v", sets) + } + if len(shots) != 2 || shots[0].attrs["fileName"] != "01_home.png" || shots[1].attrs["fileName"] != "02_list.png" { + t.Fatalf("screenshots = %+v", shots) + } + if !bytes.Equal(f.blobs[shots[0].id], first) || fmt.Sprint(shots[0].attrs["assetDeliveryState"]) != "map[state:COMPLETE]" { + t.Errorf("first screenshot not uploaded/committed: %+v", shots[0].attrs) + } + + // Same files again: nothing to do. + again, err := NewPlan(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + if !again.Empty() { + t.Errorf("second plan = %v", changeLines(again)) + } + + // A changed file is appended without --replace-screenshots... + writeFile(t, filepath.Join(shotDir, "en-US", "02_list.png"), string(pngBytes(t, 1290, 2796, 9))) + appendPlan, err := NewPlan(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + if got := changeLines(appendPlan); !reflect.DeepEqual(got, []string{"Will upload 1 screenshot in en-US APP_IPHONE_67"}) { + t.Errorf("append plan = %v", got) + } + // ...and replaces the set with it. + replace := *opts + replace.ReplaceScreenshots = true + replacePlan, err := NewPlan(ctx(), f.client(), &replace) + if err != nil { + t.Fatal(err) + } + if got := changeLines(replacePlan); !reflect.DeepEqual(got, []string{"Will delete 2 and upload 2 screenshots in en-US APP_IPHONE_67"}) { + t.Errorf("replace plan = %v", got) + } + if err := replacePlan.Apply(ctx()); err != nil { + t.Fatal(err) + } + f.mu.Lock() + n := len(f.children("appScreenshots", iphoneSet)) + f.mu.Unlock() + if n != 2 { + t.Errorf("set has %d screenshots after replace", n) + } +} + +func TestScreenshotSetLimit(t *testing.T) { + f := newFakeASC(t) + f.seed() + set := f.add("appScreenshotSets", "set-1", "vl-en", map[string]any{"screenshotDisplayType": "APP_IPHONE_67"}) + for i := range 9 { + f.addScreenshot(set.id, "old.png", []byte{byte(i)}) + } + shotDir := t.TempDir() + writeFile(t, filepath.Join(shotDir, "en-US", "a.png"), string(pngBytes(t, 1290, 2796, 1))) + writeFile(t, filepath.Join(shotDir, "en-US", "b.png"), string(pngBytes(t, 1290, 2796, 2))) + _, err := NewPlan(ctx(), f.client(), &Options{BundleID: "com.example.app", MetadataDir: t.TempDir(), ScreenshotsDir: shotDir, Screenshots: true}) + if err == nil || !strings.Contains(err.Error(), "would have 11 screenshots") { + t.Errorf("err = %v", err) + } +} + +func TestLoadScreenshotsUnknownSize(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "en-US", "odd.png"), string(pngBytes(t, 100, 200, 0))) + _, _, err := LoadScreenshots(dir) + if err == nil || !strings.Contains(err.Error(), "100x200") || !strings.Contains(err.Error(), "") { + t.Errorf("err = %v", err) + } +} + +func TestDisplayTypeForSize(t *testing.T) { + for _, tc := range []struct { + w, h int + want string + }{ + {1320, 2868, "APP_IPHONE_67"}, {2796, 1290, "APP_IPHONE_67"}, {1242, 2688, "APP_IPHONE_65"}, + {1179, 2556, "APP_IPHONE_61"}, {1242, 2208, "APP_IPHONE_55"}, {2048, 2732, "APP_IPAD_PRO_3GEN_129"}, + {1668, 2388, "APP_IPAD_PRO_3GEN_11"}, {2880, 1800, "APP_DESKTOP"}, {100, 100, ""}, + } { + if got := DisplayTypeForSize(tc.w, tc.h); got != tc.want { + t.Errorf("%dx%d = %q, want %q", tc.w, tc.h, got, tc.want) + } + } +} + +func TestNormalizeCategory(t *testing.T) { + for in, want := range map[string]string{"MZGenre.SocialNetworking": "SOCIAL_NETWORKING", "productivity\n": "PRODUCTIVITY", "PHOTO_AND_VIDEO": "PHOTO_AND_VIDEO"} { + if got := NormalizeCategory(in); got != want { + t.Errorf("%q = %q, want %q", in, got, want) + } + } +} + +func TestPullWritesFastlaneLayout(t *testing.T) { + f := newFakeASC(t) + f.seed() + f.find("appInfos", "ai-1").rels["secondaryCategory"] = map[string]any{"data": map[string]any{"type": "appCategories", "id": "UTILITIES"}} + set := f.add("appScreenshotSets", "set-1", "vl-en", map[string]any{"screenshotDisplayType": "APP_IPHONE_67"}) + shotA := pngBytes(t, 1290, 2796, 1) + f.addScreenshot(set.id, "01_home.png", shotA) + f.addScreenshot(set.id, "list.jpg", []byte("jpeg-bytes")) + metaDir, shotDir := t.TempDir(), t.TempDir() + writeFile(t, filepath.Join(metaDir, "en-US", "release_notes.txt"), "stale notes") + writeFile(t, filepath.Join(metaDir, "en-US", "keywords.txt"), "a,b\n") + writeFile(t, filepath.Join(shotDir, "en-US", "stale.png"), "x") + + opts := &Options{BundleID: "com.example.app", MetadataDir: metaDir, ScreenshotsDir: shotDir} + res, err := Pull(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + if w := f.writes(); len(w) != 0 { + t.Errorf("pull wrote to App Store Connect: %v", w) + } + for file, want := range map[string]string{ + "en-US/name.txt": "My App\n", "en-US/subtitle.txt": "Old subtitle\n", "en-US/privacy_url.txt": "https://example.com/privacy\n", + "en-US/description.txt": "Old description\n", "en-US/support_url.txt": "https://example.com/support\n", + "primary_category.txt": "GAMES\n", "secondary_category.txt": "UTILITIES\n", + } { + if got := readFile(t, filepath.Join(metaDir, file)); got != want { + t.Errorf("%s = %q, want %q", file, got, want) + } + } + if res.Unchanged != 1 || len(res.Written) != 7 || res.Version.VersionString != "1.1" || !reflect.DeepEqual(res.Locales, []string{"en-US"}) { + t.Errorf("result = %+v", res) + } + if readFile(t, filepath.Join(metaDir, "en-US", "release_notes.txt")) != "stale notes" { + t.Error("pull without --clean removed a local file") + } + if _, err := os.Stat(filepath.Join(shotDir, "en-US", "APP_IPHONE_67")); err == nil { + t.Error("screenshots downloaded without Screenshots") + } + + opts.Screenshots, opts.Clean = true, true + res, err = Pull(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(shotDir, "en-US", "APP_IPHONE_67", "01_home.png")); got != string(shotA) { + t.Error("first screenshot differs") + } + if got := readFile(t, filepath.Join(shotDir, "en-US", "APP_IPHONE_67", "02_list.jpg")); got != "jpeg-bytes" { + t.Errorf("second screenshot = %q", got) + } + wantRemoved := []string{filepath.Join(metaDir, "en-US", "release_notes.txt"), filepath.Join(shotDir, "en-US", "stale.png")} + if !reflect.DeepEqual(res.Removed, wantRemoved) || len(res.Screenshots) != 2 { + t.Errorf("removed = %v, screenshots = %v", res.Removed, res.Screenshots) + } + + // The pulled tree pushes back as no change at all. + plan, err := NewPlan(ctx(), f.client(), opts) + if err != nil { + t.Fatal(err) + } + if !plan.Empty() { + t.Errorf("round trip plan = %v", changeLines(plan)) + } +} + +// obj walks decoded JSON down the given object keys. +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} diff --git a/internal/metadata/pull.go b/internal/metadata/pull.go new file mode 100644 index 0000000..8c433da --- /dev/null +++ b/internal/metadata/pull.go @@ -0,0 +1,239 @@ +package metadata + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// PullResult is what Pull reports. Paths are as written, under the +// metadata and screenshots directories. +type PullResult struct { + App AppRef `json:"app"` + Version VersionRef `json:"version"` + Locales []string `json:"locales"` + Written []string `json:"written"` + // Unchanged counts files that already held App Store Connect's value. + Unchanged int `json:"unchanged"` + Removed []string `json:"removed"` + Screenshots []string `json:"screenshots"` + Warnings []string `json:"warnings,omitempty"` +} + +// Pull writes the App Store listing into the metadata (and, with +// Screenshots, screenshots) directory. Empty fields get no file. Local files +// App Store Connect has no value for are kept unless Clean is set. +func Pull(ctx context.Context, client *asc.Client, opts *Options) (*PullResult, error) { + app, err := client.AppByBundleID(ctx, opts.BundleID) + if err != nil { + return nil, err + } + t, err := resolveTarget(ctx, client, app, opts.Version, false) + if err != nil { + return nil, err + } + res := &PullResult{App: AppRef{ID: app.ID, Name: app.Name, BundleID: app.BundleID}, Version: versionRef(t.version), Locales: []string{}, Written: []string{}, Removed: []string{}, Screenshots: []string{}} + logf(opts.Log, "Pulling %s version %s (%s)", app.Name, res.Version.VersionString, res.Version.State) + infoLocs, err := client.ListAppInfoLocalizations(ctx, t.info.ID) + if err != nil { + return nil, err + } + versionLocs, err := client.ListVersionLocalizations(ctx, t.version.ID) + if err != nil { + return res, err + } + info, version := byLocale(infoLocs), byLocale(versionLocs) + locales := map[string]bool{} + for l := range info { + locales[l] = true + } + for l := range version { + locales[l] = true + } + res.Locales = sortedKeys(locales) + + dir := opts.metadataDir() + keep := map[string]bool{} + write := func(path, value string) error { + keep[path] = true + if existing, err := os.ReadFile(path); err == nil && normalize(string(existing)) == value { + res.Unchanged++ + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(value+"\n"), 0o644); err != nil { + return err + } + res.Written = append(res.Written, path) + return nil + } + for _, locale := range res.Locales { + for _, f := range Fields { + src := version[locale] + if f.AppInfo { + src = info[locale] + } + if v := normalize(src.Fields[f.Attr]); v != "" { + if err := write(filepath.Join(dir, locale, f.File+".txt"), v); err != nil { + return res, err + } + } + } + } + for file, id := range map[string]string{PrimaryCategoryFile: t.info.PrimaryCategoryID, SecondaryCategoryFile: t.info.SecondaryCategoryID} { + if id != "" { + if err := write(filepath.Join(dir, file+".txt"), id); err != nil { + return res, err + } + } + } + + if opts.Screenshots { + if err := pullScreenshots(ctx, client, opts, versionLocs, keep, res); err != nil { + return res, err + } + } + if opts.Clean { + if err := clean(opts, keep, res); err != nil { + return res, err + } + } + sort.Strings(res.Written) + return res, nil +} + +var orderPrefix = regexp.MustCompile(`^\d+_`) + +// screenshotName is the local name of the i-th screenshot: an order prefix +// (replacing one the uploaded name already had) and the uploaded file name. +func screenshotName(i int, fileName string) string { + base := filepath.Base(strings.ReplaceAll(fileName, "\\", "/")) + if base == "." || base == "/" || base == "" { + base = "screenshot.png" + } + return fmt.Sprintf("%02d_%s", i+1, orderPrefix.ReplaceAllString(base, "")) +} + +// pullScreenshots downloads every set into screenshots///, +// skipping files whose MD5 already matches. +func pullScreenshots(ctx context.Context, client *asc.Client, opts *Options, locs []asc.Localization, keep map[string]bool, res *PullResult) error { + for _, loc := range locs { + sets, err := client.ListAppScreenshotSets(ctx, loc.ID) + if err != nil { + return err + } + for _, set := range sets { + shots, err := client.ListAppScreenshots(ctx, set.ID) + if err != nil { + return err + } + for i := range shots { + s := &shots[i] + path := filepath.Join(opts.screenshotsDir(), loc.Locale, set.DisplayType, screenshotName(i, s.FileName)) + keep[path] = true + if s.TemplateURL == "" { + res.Warnings = append(res.Warnings, fmt.Sprintf("%s %s %s is still processing (%s); not downloaded", loc.Locale, set.DisplayType, s.FileName, s.State)) + continue + } + if sum, err := asc.FileMD5(path); err == nil && strings.EqualFold(sum, s.Checksum) { + res.Unchanged++ + continue + } + if err := download(ctx, client, s.ImageURL(), path); err != nil { + return err + } + res.Screenshots = append(res.Screenshots, path) + } + } + } + return nil +} + +// download writes to a temporary file first so an interrupted pull leaves +// no truncated image behind. +func download(ctx context.Context, client *asc.Client, url, path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".download-*") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if err := client.Download(ctx, url, tmp); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +// clean removes the field, category and (with Screenshots) image files that +// Pull did not just write or confirm. +func clean(opts *Options, keep map[string]bool, res *PullResult) error { + remove := func(path string) error { + if keep[path] { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + res.Removed = append(res.Removed, path) + return nil + } + dir := opts.metadataDir() + for _, file := range []string{PrimaryCategoryFile, SecondaryCategoryFile} { + if path := filepath.Join(dir, file+".txt"); fileExists(path) { + if err := remove(path); err != nil { + return err + } + } + } + entries, err := os.ReadDir(dir) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + for _, e := range entries { + if !e.IsDir() || skippedDirs[e.Name()] || strings.HasPrefix(e.Name(), ".") { + continue + } + for _, f := range Fields { + if path := filepath.Join(dir, e.Name(), f.File+".txt"); fileExists(path) { + if err := remove(path); err != nil { + return err + } + } + } + } + if !opts.Screenshots { + return nil + } + err = filepath.WalkDir(opts.screenshotsDir(), func(path string, d fs.DirEntry, err error) error { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil || d.IsDir() || !isImage(d.Name()) { + return err + } + return remove(path) + }) + sort.Strings(res.Removed) + return err +} + +func fileExists(path string) bool { + st, err := os.Stat(path) + return err == nil && !st.IsDir() +} diff --git a/internal/metadata/push.go b/internal/metadata/push.go new file mode 100644 index 0000000..75d6475 --- /dev/null +++ b/internal/metadata/push.go @@ -0,0 +1,475 @@ +package metadata + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + "unicode/utf8" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// Change kinds. +const ( + ChangeCreateVersion = "create_version" + ChangeCreateLocale = "create_locale" + ChangeUpdate = "update" + ChangeCategory = "category" + ChangeScreenshots = "screenshots" +) + +// Change is one line of the plan. +type Change struct { + Kind string `json:"kind"` + Locale string `json:"locale,omitempty"` + // Field is the fastlane file name (description, keywords, ...), the + // category file for a category change, and "app_info" or "version" for + // a new locale. + Field string `json:"field,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + DisplayType string `json:"display_type,omitempty"` + Upload []string `json:"upload,omitempty"` + Delete int `json:"delete,omitempty"` +} + +// longFields are summarized by length rather than printed in full. +var longFields = map[string]bool{"description": true, "release_notes": true, "promotional_text": true} + +func quote(s string) string { + if s == "" { + return "(empty)" + } + return fmt.Sprintf("%q", s) +} + +// String renders the change as a "Will ..." line. +func (c *Change) String() string { + switch c.Kind { + case ChangeCreateVersion: + return "Will create App Store version " + c.To + case ChangeCreateLocale: + if c.Field == "app_info" { + return "Will add locale " + c.Locale + " to the app info (name, subtitle, privacy URL)" + } + return "Will add locale " + c.Locale + " to the version" + case ChangeUpdate: + if longFields[c.Field] { + return fmt.Sprintf("Will update %s %s (%d → %d characters)", c.Locale, c.Field, utf8.RuneCountInString(c.From), utf8.RuneCountInString(c.To)) + } + return fmt.Sprintf("Will update %s %s: %s → %s", c.Locale, c.Field, quote(c.From), quote(c.To)) + case ChangeCategory: + return fmt.Sprintf("Will set %s: %s → %s", strings.ReplaceAll(c.Field, "_", " "), quote(c.From), quote(c.To)) + case ChangeScreenshots: + var parts []string + if c.Delete > 0 { + parts = append(parts, fmt.Sprintf("delete %d", c.Delete)) + } + if len(c.Upload) > 0 { + parts = append(parts, fmt.Sprintf("upload %d", len(c.Upload))) + } + noun := "screenshots" + if c.Delete+len(c.Upload) == 1 { + noun = "screenshot" + } + return fmt.Sprintf("Will %s %s in %s %s", strings.Join(parts, " and "), noun, c.Locale, c.DisplayType) + } + return c.Kind +} + +type locWrite struct { + appInfo bool + locale string + id string // empty: create + fields map[string]string +} + +type shotWrite struct { + locale string + displayType string + setID string // empty: create + deleteIDs []string + upload []string +} + +// Plan is what a push would change. Build it with NewPlan, print Changes, +// then Apply. +type Plan struct { + App AppRef `json:"app"` + Version VersionRef `json:"version"` + Changes []Change `json:"changes"` + Warnings []string `json:"warnings,omitempty"` + Applied bool `json:"applied"` + + client *asc.Client + opts *Options + local *Local + shots LocalScreenshots + target *target + createVersion string + writes []locWrite + primary *string + secondary *string + shotWrites []shotWrite + versionLocIDs map[string]string +} + +// Empty reports whether the plan changes nothing. +func (p *Plan) Empty() bool { return len(p.Changes) == 0 } + +// NewPlan reads and validates the local files, then diffs them against App +// Store Connect. Nothing is written. +func NewPlan(ctx context.Context, client *asc.Client, opts *Options) (*Plan, error) { + local, err := LoadLocal(opts.metadataDir()) + if err != nil { + return nil, err + } + if err := local.Validate(); err != nil { + return nil, err + } + p := &Plan{client: client, opts: opts, local: local, Changes: []Change{}} + if opts.Screenshots { + if p.shots, p.Warnings, err = LoadScreenshots(opts.screenshotsDir()); err != nil { + return nil, err + } + } + if len(local.Locales) == 0 && local.Primary == nil && local.Secondary == nil && len(p.shots) == 0 { + return nil, fmt.Errorf("nothing to push: %s has no /.txt files", opts.metadataDir()) + } + app, err := client.AppByBundleID(ctx, opts.BundleID) + if err != nil { + return nil, err + } + p.App = AppRef{ID: app.ID, Name: app.Name, BundleID: app.BundleID} + t, err := resolveTarget(ctx, client, app, opts.Version, true) + if err != nil { + return nil, err + } + if err := p.compute(ctx, t); err != nil { + return nil, err + } + return p, nil +} + +// compute fills Changes and the writes from the target's current values. +func (p *Plan) compute(ctx context.Context, t *target) error { + p.target, p.writes, p.shotWrites, p.primary, p.secondary = t, nil, nil, nil, nil + p.Changes = p.Changes[:0] + p.versionLocIDs = map[string]string{} + versionForDiff := t.version + if t.version != nil { + p.Version = versionRef(t.version) + } else { + p.createVersion = p.opts.Version + p.Version = VersionRef{VersionString: p.opts.Version} + p.Changes = append(p.Changes, Change{Kind: ChangeCreateVersion, To: p.opts.Version}) + versionForDiff = t.base + } + + infoLocs, err := p.client.ListAppInfoLocalizations(ctx, t.info.ID) + if err != nil { + return err + } + var versionLocs []asc.Localization + if versionForDiff != nil { + if versionLocs, err = p.client.ListVersionLocalizations(ctx, versionForDiff.ID); err != nil { + return err + } + } + if t.version != nil { + for _, l := range versionLocs { + p.versionLocIDs[l.Locale] = l.ID + } + } + infoChanged := false + for _, appInfo := range []bool{true, false} { + remote := byLocale(versionLocs) + if appInfo { + remote = byLocale(infoLocs) + } + for _, locale := range p.local.LocaleNames() { + w, changes, err := diffLocale(locale, appInfo, p.local.Locales[locale], remote) + if err != nil { + return err + } + if w == nil { + continue + } + if t.version == nil && !appInfo { + w.id = "" // the new version's localizations get new IDs; Apply re-plans + } + infoChanged = infoChanged || appInfo + p.writes = append(p.writes, *w) + p.Changes = append(p.Changes, changes...) + } + } + + for _, c := range []struct { + file string + local *string + remote string + dst **string + }{{PrimaryCategoryFile, p.local.Primary, t.info.PrimaryCategoryID, &p.primary}, {SecondaryCategoryFile, p.local.Secondary, t.info.SecondaryCategoryID, &p.secondary}} { + if c.local != nil && *c.local != c.remote { + *c.dst = c.local + infoChanged = true + p.Changes = append(p.Changes, Change{Kind: ChangeCategory, Field: c.file, From: c.remote, To: *c.local}) + } + } + if infoChanged && t.version != nil && !t.info.Editable() { + return fmt.Errorf("the app info (name, subtitle, privacy URL, categories) is %s and cannot be edited; it opens again with a new App Store version (--version X.Y)", t.info.State) + } + + if len(p.shots) > 0 { + return p.computeScreenshots(ctx, t.version != nil) + } + return nil +} + +// diffLocale compares one locale's files with App Store Connect. A nil +// write means nothing differs. +func diffLocale(locale string, appInfo bool, values map[string]string, remote map[string]asc.Localization) (*locWrite, []Change, error) { + existing, exists := remote[locale] + w := &locWrite{appInfo: appInfo, locale: locale, id: existing.ID, fields: map[string]string{}} + var changes []Change + for _, f := range Fields { + v, ok := values[f.File] + if f.AppInfo != appInfo || !ok { + continue + } + from := normalize(existing.Fields[f.Attr]) + if exists && v == from { + continue + } + if !exists && v == "" { + continue + } + w.fields[f.Attr] = v + changes = append(changes, Change{Kind: ChangeUpdate, Locale: locale, Field: f.File, From: from, To: v}) + } + if len(w.fields) == 0 { + return nil, nil, nil + } + if !exists { + if appInfo && w.fields[asc.AttrName] == "" { + return nil, nil, fmt.Errorf("locale %s is new to the app info, which needs a name: add %s/name.txt", locale, locale) + } + kind := "version" + if appInfo { + kind = "app_info" + } + changes = append([]Change{{Kind: ChangeCreateLocale, Locale: locale, Field: kind}}, changes...) + } + return w, changes, nil +} + +// computeScreenshots diffs each local set with App Store Connect by MD5. A +// version that does not exist yet has no sets, so everything is uploaded. +func (p *Plan) computeScreenshots(ctx context.Context, versionExists bool) error { + for _, locale := range sortedKeys(p.shots) { + sets := map[string]asc.AppScreenshotSet{} + if id := p.versionLocIDs[locale]; id != "" && versionExists { + remoteSets, err := p.client.ListAppScreenshotSets(ctx, id) + if err != nil { + return err + } + for _, s := range remoteSets { + sets[s.DisplayType] = s + } + } + for _, displayType := range sortedKeys(p.shots[locale]) { + paths := p.shots[locale][displayType] + var remote []asc.AppScreenshot + set, hasSet := sets[displayType] + if hasSet { + var err error + if remote, err = p.client.ListAppScreenshots(ctx, set.ID); err != nil { + return err + } + } + w, err := p.diffSet(locale, displayType, set.ID, paths, remote) + if err != nil { + return err + } + if w == nil { + continue + } + p.shotWrites = append(p.shotWrites, *w) + ch := Change{Kind: ChangeScreenshots, Locale: locale, DisplayType: displayType, Delete: len(w.deleteIDs)} + for _, path := range w.upload { + ch.Upload = append(ch.Upload, filepath.Base(path)) + } + p.Changes = append(p.Changes, ch) + } + } + return nil +} + +func (p *Plan) diffSet(locale, displayType, setID string, paths []string, remote []asc.AppScreenshot) (*shotWrite, error) { + sums := make([]string, len(paths)) + for i, path := range paths { + sum, err := asc.FileMD5(path) + if err != nil { + return nil, err + } + sums[i] = sum + } + w := &shotWrite{locale: locale, displayType: displayType, setID: setID} + if p.opts.ReplaceScreenshots { + same := len(sums) == len(remote) + for i := 0; same && i < len(sums); i++ { + same = strings.EqualFold(sums[i], remote[i].Checksum) + } + if same { + return nil, nil + } + for i := range remote { + w.deleteIDs = append(w.deleteIDs, remote[i].ID) + } + w.upload = paths + return w, nil + } + have := map[string]int{} + for i := range remote { + have[strings.ToLower(remote[i].Checksum)]++ + } + for i, sum := range sums { + if have[sum] > 0 { + have[sum]-- + continue + } + w.upload = append(w.upload, paths[i]) + } + if len(w.upload) == 0 { + return nil, nil + } + if n := len(remote) + len(w.upload); n > MaxScreenshotsPerSet { + return nil, fmt.Errorf("%s %s would have %d screenshots (%d in App Store Connect + %d new), the limit is %d; pass --replace-screenshots to replace the set", locale, displayType, n, len(remote), len(w.upload), MaxScreenshotsPerSet) + } + return w, nil +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// Apply writes the plan: the version (when new, after which the plan is +// recomputed against it), app info locales, version locales, categories, +// then screenshots. It stops at the first error. +func (p *Plan) Apply(ctx context.Context) error { + log := p.opts.Log + if p.createVersion != "" { + logf(log, "Creating App Store version %s...", p.createVersion) + v, err := p.client.CreateAppStoreVersion(ctx, p.target.app.ID, asc.PlatformIOS, p.createVersion) + if err != nil { + return fmt.Errorf("create App Store version %s: %w", p.createVersion, err) + } + // The new version starts with copies of the previous one's + // localizations, and App Store Connect opens an editable app info. + t, err := resolveTarget(ctx, p.client, p.target.app, v.VersionString, true) + if err != nil { + return err + } + p.createVersion = "" + if err := p.compute(ctx, t); err != nil { + return err + } + p.Version.Created = true + p.Changes = append([]Change{{Kind: ChangeCreateVersion, To: v.VersionString}}, p.Changes...) + } + for i := range p.writes { + if err := p.applyLocale(ctx, &p.writes[i]); err != nil { + return err + } + } + if p.primary != nil || p.secondary != nil { + logf(log, "Updating categories...") + if err := p.client.UpdateAppInfoCategories(ctx, p.target.info.ID, p.primary, p.secondary); err != nil { + return fmt.Errorf("update categories: %w", err) + } + } + if err := p.applyScreenshots(ctx); err != nil { + return err + } + p.Applied = true + return nil +} + +func (p *Plan) applyLocale(ctx context.Context, w *locWrite) error { + what := "version" + if w.appInfo { + what = "app info" + } + logf(p.opts.Log, "Updating %s %s...", w.locale, what) + var err error + switch { + case w.appInfo && w.id == "": + _, err = p.client.CreateAppInfoLocalization(ctx, p.target.info.ID, w.locale, w.fields) + case w.appInfo: + err = p.client.UpdateAppInfoLocalization(ctx, w.id, w.fields) + case w.id == "": + var l *asc.Localization + if l, err = p.client.CreateVersionLocalization(ctx, p.target.version.ID, w.locale, w.fields); err == nil { + p.versionLocIDs[w.locale] = l.ID + } + default: + err = p.client.UpdateVersionLocalization(ctx, w.id, w.fields) + } + if err != nil { + return fmt.Errorf("update %s %s: %w", w.locale, what, err) + } + return nil +} + +func (p *Plan) applyScreenshots(ctx context.Context) error { + var uploaded []*asc.AppScreenshot + for _, w := range p.shotWrites { + locID := p.versionLocIDs[w.locale] + if locID == "" { + logf(p.opts.Log, "Adding locale %s to the version for its screenshots...", w.locale) + l, err := p.client.CreateVersionLocalization(ctx, p.target.version.ID, w.locale, nil) + if err != nil { + return fmt.Errorf("add locale %s: %w", w.locale, err) + } + locID = l.ID + p.versionLocIDs[w.locale] = locID + } + setID := w.setID + if setID == "" { + set, err := p.client.CreateAppScreenshotSet(ctx, locID, w.displayType) + if err != nil { + return fmt.Errorf("create %s %s screenshot set: %w", w.locale, w.displayType, err) + } + setID = set.ID + } + for _, id := range w.deleteIDs { + if err := p.client.DeleteAppScreenshot(ctx, id); err != nil { + return fmt.Errorf("delete %s %s screenshot %s: %w", w.locale, w.displayType, id, err) + } + } + for _, path := range w.upload { + logf(p.opts.Log, "Uploading %s (%s %s)...", filepath.Base(path), w.locale, w.displayType) + shot, err := p.client.UploadScreenshot(ctx, setID, path) + if err != nil { + return err + } + uploaded = append(uploaded, shot) + } + } + if len(uploaded) > 0 { + logf(p.opts.Log, "Waiting for App Store Connect to process %d screenshots...", len(uploaded)) + } + for _, shot := range uploaded { + if _, err := p.client.WaitForScreenshot(ctx, shot.ID, p.opts.pollInterval()); err != nil { + return err + } + } + return nil +} diff --git a/internal/metadata/screenshots.go b/internal/metadata/screenshots.go new file mode 100644 index 0000000..fb8638d --- /dev/null +++ b/internal/metadata/screenshots.go @@ -0,0 +1,178 @@ +package metadata + +import ( + "errors" + "fmt" + "image" + _ "image/jpeg" // DecodeConfig for .jpg screenshots + _ "image/png" // DecodeConfig for .png screenshots + "os" + "path/filepath" + "sort" + "strings" +) + +// MaxScreenshotsPerSet is App Store Connect's limit per display type and locale. +const MaxScreenshotsPerSet = 10 + +// DisplayTypes are the screenshotDisplayType values a subfolder of +// screenshots// may be named after. +var DisplayTypes = []string{ + "APP_IPHONE_67", "APP_IPHONE_65", "APP_IPHONE_61", "APP_IPHONE_58", "APP_IPHONE_55", + "APP_IPHONE_47", "APP_IPHONE_40", "APP_IPHONE_35", + "APP_IPAD_PRO_3GEN_129", "APP_IPAD_PRO_3GEN_11", "APP_IPAD_PRO_129", "APP_IPAD_105", "APP_IPAD_97", + "APP_DESKTOP", "APP_APPLE_TV", "APP_APPLE_VISION_PRO", + "APP_WATCH_ULTRA", "APP_WATCH_SERIES_10", "APP_WATCH_SERIES_7", "APP_WATCH_SERIES_4", "APP_WATCH_SERIES_3", + "IMESSAGE_APP_IPHONE_67", "IMESSAGE_APP_IPHONE_65", "IMESSAGE_APP_IPHONE_61", "IMESSAGE_APP_IPHONE_58", + "IMESSAGE_APP_IPHONE_55", "IMESSAGE_APP_IPHONE_47", "IMESSAGE_APP_IPHONE_40", + "IMESSAGE_APP_IPAD_PRO_3GEN_129", "IMESSAGE_APP_IPAD_PRO_3GEN_11", "IMESSAGE_APP_IPAD_PRO_129", + "IMESSAGE_APP_IPAD_105", "IMESSAGE_APP_IPAD_97", +} + +// pixelSizes maps a portrait size ("WxH") to the display type it is +// inferred as; landscape images are matched rotated. Sizes several display +// types accept (2048x2732 is both 12.9" iPad generations) go to the newer +// one; a subfolder named after the display type overrides the inference. +var pixelSizes = map[string]string{ + "1320x2868": "APP_IPHONE_67", // 6.9" + "1290x2796": "APP_IPHONE_67", + "1260x2736": "APP_IPHONE_67", + "1284x2778": "APP_IPHONE_65", + "1242x2688": "APP_IPHONE_65", + "1206x2622": "APP_IPHONE_61", // 6.3" + "1179x2556": "APP_IPHONE_61", + "1170x2532": "APP_IPHONE_61", + "1125x2436": "APP_IPHONE_58", + "1080x2340": "APP_IPHONE_58", + "1242x2208": "APP_IPHONE_55", + "750x1334": "APP_IPHONE_47", + "640x1136": "APP_IPHONE_40", + "640x960": "APP_IPHONE_35", + "2064x2752": "APP_IPAD_PRO_3GEN_129", // 13" + "2048x2732": "APP_IPAD_PRO_3GEN_129", + "1668x2420": "APP_IPAD_PRO_3GEN_11", + "1668x2388": "APP_IPAD_PRO_3GEN_11", + "1640x2360": "APP_IPAD_PRO_3GEN_11", + "1488x2266": "APP_IPAD_PRO_3GEN_11", + "1668x2224": "APP_IPAD_105", + "1536x2048": "APP_IPAD_97", + "800x1280": "APP_DESKTOP", + "900x1440": "APP_DESKTOP", + "1600x2560": "APP_DESKTOP", + "1800x2880": "APP_DESKTOP", +} + +// DisplayTypeForSize infers the display type from pixel dimensions; "" when unknown. +func DisplayTypeForSize(width, height int) string { + if width > height { + width, height = height, width + } + return pixelSizes[fmt.Sprintf("%dx%d", width, height)] +} + +func knownDisplayType(name string) bool { + for _, t := range DisplayTypes { + if t == name { + return true + } + } + return false +} + +func isImage(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".png", ".jpg", ".jpeg": + return true + } + return false +} + +func imageSize(path string) (width, height int, err error) { + f, err := os.Open(path) + if err != nil { + return 0, 0, err + } + defer f.Close() + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return 0, 0, fmt.Errorf("%s: %w", path, err) + } + return cfg.Width, cfg.Height, nil +} + +// LocalScreenshots maps locale → display type → image paths in upload order. +type LocalScreenshots map[string]map[string][]string + +// LoadScreenshots reads screenshots//: images directly inside are +// assigned by pixel size, images in a subfolder named after a display type +// (APP_IPHONE_67, ...) go to that type. Files are ordered by name. Other +// subfolders (fastlane's iMessage, ...) are skipped and returned as warnings. +func LoadScreenshots(dir string) (LocalScreenshots, []string, error) { + shots := LocalScreenshots{} + var warnings, problems []string + locales, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return shots, nil, nil + } + if err != nil { + return nil, nil, err + } + add := func(locale, displayType, path string) { + if shots[locale] == nil { + shots[locale] = map[string][]string{} + } + shots[locale][displayType] = append(shots[locale][displayType], path) + } + for _, l := range locales { + if !l.IsDir() || strings.HasPrefix(l.Name(), ".") || skippedDirs[l.Name()] { + continue + } + localeDir := filepath.Join(dir, l.Name()) + entries, err := os.ReadDir(localeDir) + if err != nil { + return nil, nil, err + } + for _, e := range entries { + path := filepath.Join(localeDir, e.Name()) + switch { + case strings.HasPrefix(e.Name(), "."): + case e.IsDir() && knownDisplayType(e.Name()): + files, err := os.ReadDir(path) + if err != nil { + return nil, nil, err + } + for _, f := range files { + if !f.IsDir() && isImage(f.Name()) { + add(l.Name(), e.Name(), filepath.Join(path, f.Name())) + } + } + case e.IsDir(): + warnings = append(warnings, fmt.Sprintf("skipped %s: not a display type (%s, ...)", path, strings.Join(DisplayTypes[:3], ", "))) + case isImage(e.Name()): + w, h, err := imageSize(path) + if err != nil { + return nil, nil, err + } + displayType := DisplayTypeForSize(w, h) + if displayType == "" { + problems = append(problems, fmt.Sprintf("%s is %dx%d, which matches no display type; move it into a subfolder named after one (%s//)", path, w, h, localeDir)) + continue + } + add(l.Name(), displayType, path) + } + } + } + for locale, sets := range shots { + for displayType, paths := range sets { + sort.Strings(paths) + if len(paths) > MaxScreenshotsPerSet { + problems = append(problems, fmt.Sprintf("%s %s has %d screenshots, the limit is %d", locale, displayType, len(paths), MaxScreenshotsPerSet)) + } + } + } + if len(problems) > 0 { + sort.Strings(problems) + return nil, warnings, fmt.Errorf("screenshots are not valid:\n %s", strings.Join(problems, "\n ")) + } + return shots, warnings, nil +} From aa9e47611a3b573abb35a64efae0c0d3e600e3fd Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:08 +0200 Subject: [PATCH 3/5] metadata: builder ios metadata pull and push push prints a Will line per difference and needs --yes (--dry-run prints only); pull never deletes local files without --clean; screenshots are opt-in with --screenshots on both. --- cmd/builder/metadata.go | 166 +++++++++++++++++++++++++++++++++++ cmd/builder/metadata_test.go | 165 ++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 cmd/builder/metadata.go create mode 100644 cmd/builder/metadata_test.go diff --git a/cmd/builder/metadata.go b/cmd/builder/metadata.go new file mode 100644 index 0000000..951bb22 --- /dev/null +++ b/cmd/builder/metadata.go @@ -0,0 +1,166 @@ +package main + +import ( + "fmt" + + "github.com/MobAI-App/ios-builder/internal/metadata" + "github.com/spf13/cobra" +) + +var iosMetadataCmd = &cobra.Command{ + Use: "metadata", + Short: "Pull and push the App Store listing (text, categories, screenshots)", + Long: `Syncs the App Store listing with files laid out like fastlane deliver's, so +an existing fastlane setup works unchanged: + + metadata//name.txt, subtitle.txt, privacy_url.txt (app info) + metadata//description.txt, keywords.txt, release_notes.txt, + promotional_text.txt, marketing_url.txt, support_url.txt + metadata/primary_category.txt, secondary_category.txt (PRODUCTIVITY, ...) + screenshots//*.png|jpg display type from the pixel size + screenshots///*.png explicit, e.g. APP_IPHONE_67 + +The version worked on is the one being prepared for submission, or --version. +The app is identified by --bundle-id, --ipa, ios.bundleId in builder.json, or +the newest IPA in ./dist. Needs an App Store Connect API key: builder auth apple.`, +} + +var iosMetadataPullCmd = &cobra.Command{ + Use: "pull", + Short: "Write the App Store listing into ./metadata (and ./screenshots)", + Long: `Writes every non-empty field of the version being prepared (else the newest +version) into the metadata directory. Local files App Store Connect has no +value for are kept unless --clean. Screenshots download only with --screenshots, +into screenshots///NN_.`, + Args: cobra.NoArgs, + RunE: runIOSMetadataPull, +} + +var iosMetadataPushCmd = &cobra.Command{ + Use: "push", + Short: "Update the App Store listing from ./metadata (and ./screenshots)", + Long: `Compares the files with App Store Connect, prints a "Will ..." line per +difference and, with --yes, writes only those. Fields without a file are left +alone; an empty file clears the field. Lengths (name and subtitle 30, +keywords 100, promotional text 170, description and release notes 4000), URLs, +categories and screenshot sizes are checked before anything is sent. + +--version X.Y targets that version, creating it when it does not exist. +Screenshots are compared by checksum and only pushed with --screenshots: +missing ones are appended to their set, and --replace-screenshots deletes a +set's screenshots and uploads the local ones whenever the two differ.`, + Args: cobra.NoArgs, + RunE: runIOSMetadataPush, +} + +func init() { + for _, cmd := range []*cobra.Command{iosMetadataPullCmd, iosMetadataPushCmd} { + cmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") + cmd.Flags().String("ipa", "", "Read the bundle ID from this IPA") + cmd.Flags().String("version", "", "App Store version (default: the one being prepared for submission)") + cmd.Flags().String("metadata-dir", "metadata", "Directory of /.txt files") + cmd.Flags().String("screenshots-dir", "screenshots", "Directory of / screenshot folders") + cmd.Flags().Bool("screenshots", false, "Include screenshots") + cmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") + } + iosMetadataPullCmd.Flags().Bool("clean", false, "Delete local files App Store Connect has no value for") + iosMetadataPushCmd.Flags().Bool("yes", false, "Apply the changes (without it the plan is printed and the command fails)") + iosMetadataPushCmd.Flags().Bool("dry-run", false, "Print the plan and exit successfully without changing anything") + iosMetadataPushCmd.Flags().Bool("replace-screenshots", false, "Replace a set's screenshots when they differ from the local ones") + iosMetadataCmd.AddCommand(iosMetadataPullCmd, iosMetadataPushCmd) + iosCmd.AddCommand(iosMetadataCmd) +} + +func metadataOptions(cmd *cobra.Command, log output) (*metadata.Options, error) { + bundleID, _, err := resolveApp(cmd) + if err != nil { + return nil, err + } + opts := &metadata.Options{BundleID: bundleID, Log: log.log} + opts.Version, _ = cmd.Flags().GetString("version") + opts.MetadataDir, _ = cmd.Flags().GetString("metadata-dir") + opts.ScreenshotsDir, _ = cmd.Flags().GetString("screenshots-dir") + opts.Screenshots, _ = cmd.Flags().GetBool("screenshots") + if cmd.Flags().Lookup("clean") != nil { + opts.Clean, _ = cmd.Flags().GetBool("clean") + } + if cmd.Flags().Lookup("replace-screenshots") != nil { + opts.ReplaceScreenshots, _ = cmd.Flags().GetBool("replace-screenshots") + } + return opts, nil +} + +func runIOSMetadataPull(cmd *cobra.Command, _ []string) error { + client, err := getASCClient() + if err != nil { + return err + } + out := newOutput(cmd) + opts, err := metadataOptions(cmd, out) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd, false) + defer cancel() + res, err := metadata.Pull(ctx, client, opts) + if res != nil { + for _, w := range res.Warnings { + logf(out.log, "Warning: %s", w) + } + } + return finish(out, cmd, res, err, func() { + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Pulled %s version %s: %d locales, %d files written, %d unchanged", res.App.Name, res.Version.VersionString, len(res.Locales), len(res.Written), res.Unchanged) + if len(res.Screenshots) > 0 { + fmt.Fprintf(w, ", %d screenshots downloaded", len(res.Screenshots)) + } + if len(res.Removed) > 0 { + fmt.Fprintf(w, ", %d removed", len(res.Removed)) + } + fmt.Fprintln(w) + }) +} + +func runIOSMetadataPush(cmd *cobra.Command, _ []string) error { + yes, _ := cmd.Flags().GetBool("yes") + dryRun, _ := cmd.Flags().GetBool("dry-run") + if yes && dryRun { + return fmt.Errorf("--yes and --dry-run exclude each other") + } + client, err := getASCClient() + if err != nil { + return err + } + out := newOutput(cmd) + opts, err := metadataOptions(cmd, out) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd, false) + defer cancel() + plan, err := metadata.NewPlan(ctx, client, opts) + if err != nil { + return err + } + for _, w := range plan.Warnings { + logf(out.log, "Warning: %s", w) + } + if plan.Empty() { + logf(out.log, "%s version %s already matches %s; nothing to push", plan.App.Name, plan.Version.VersionString, opts.MetadataDir) + return finish(out, cmd, plan, nil, nil) + } + logf(out.log, "%s version %s:", plan.App.Name, plan.Version.VersionString) + for i := range plan.Changes { + logf(out.log, " %s", plan.Changes[i].String()) + } + if dryRun { + return finish(out, cmd, plan, nil, nil) + } + if !yes { + return finish(out, cmd, plan, fmt.Errorf("pass --yes to apply these %d changes (or --dry-run to only print them)", len(plan.Changes)), nil) + } + err = plan.Apply(ctx) + return finish(out, cmd, plan, err, func() { + fmt.Fprintf(cmd.OutOrStdout(), "Updated %s version %s: %d changes\n", plan.App.Name, plan.Version.VersionString, len(plan.Changes)) + }) +} diff --git a/cmd/builder/metadata_test.go b/cmd/builder/metadata_test.go new file mode 100644 index 0000000..46b4931 --- /dev/null +++ b/cmd/builder/metadata_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/metadata" +) + +// metadataFake serves one app with an editable version 2.0 in en-US and +// records the writes. +type metadataFake struct { + mu sync.Mutex + writes []string + bodies map[string]map[string]any +} + +func newMetadataFake(t *testing.T) *metadataFake { + t.Helper() + f := &metadataFake{bodies: map[string]map[string]any{}} + list := func(w http.ResponseWriter, rs ...map[string]any) { + writeJSON(w, 200, map[string]any{"data": rs}) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + if r.Method != http.MethodGet { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.writes = append(f.writes, key) + f.bodies[key] = body + f.mu.Unlock() + } + switch key { + case "GET /v1/apps": + list(w, map[string]any{"type": "apps", "id": "app-1", "attributes": map[string]any{"bundleId": "com.example.app", "name": "Example"}}) + case "GET /v1/apps/app-1/appStoreVersions": + list(w, + map[string]any{"type": "appStoreVersions", "id": "v-1", "attributes": map[string]any{"versionString": "1.0", "appVersionState": "READY_FOR_DISTRIBUTION", "createdDate": "2026-01-01T00:00:00Z"}}, + map[string]any{"type": "appStoreVersions", "id": "v-2", "attributes": map[string]any{"versionString": "2.0", "appVersionState": "PREPARE_FOR_SUBMISSION", "createdDate": "2026-09-01T00:00:00Z"}}) + case "GET /v1/apps/app-1/appInfos": + list(w, map[string]any{"type": "appInfos", "id": "ai-1", "attributes": map[string]any{"state": "PREPARE_FOR_SUBMISSION"}, + "relationships": map[string]any{"primaryCategory": map[string]any{"data": map[string]any{"type": "appCategories", "id": "UTILITIES"}}}}) + case "GET /v1/appInfos/ai-1/appInfoLocalizations": + list(w, map[string]any{"type": "appInfoLocalizations", "id": "il-1", "attributes": map[string]any{"locale": "en-US", "name": "Example", "subtitle": "Does things"}}) + case "GET /v1/appStoreVersions/v-2/appStoreVersionLocalizations": + list(w, map[string]any{"type": "appStoreVersionLocalizations", "id": "vl-1", "attributes": map[string]any{"locale": "en-US", "description": "An app.", "keywords": "one,two"}}) + case "PATCH /v1/appStoreVersionLocalizations/vl-1": + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "appStoreVersionLocalizations", "id": "vl-1"}}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } + })) + t.Cleanup(srv.Close) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + previous := getASCClient + getASCClient = func() (*asc.Client, error) { + return asc.NewClient(creds, asc.WithBaseURL(srv.URL), asc.WithRetryDelay(time.Millisecond)) + } + t.Cleanup(func() { getASCClient = previous }) + return f +} + +func (f *metadataFake) written() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.writes...) +} + +func TestMetadataPullThenPush(t *testing.T) { + f := newMetadataFake(t) + t.Chdir(t.TempDir()) + + stdout, stderr, err := run(t, "ios", "metadata", "pull", "--bundle-id", "com.example.app", "--json") + if err != nil { + t.Fatalf("%v\n%s", err, stderr) + } + var pulled metadata.PullResult + if err := json.Unmarshal([]byte(stdout), &pulled); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout) + } + if pulled.Version.VersionString != "2.0" || len(pulled.Written) != 5 { + t.Errorf("pull = %+v", pulled) + } + data, err := os.ReadFile(filepath.Join("metadata", "en-US", "keywords.txt")) + if err != nil || string(data) != "one,two\n" { + t.Errorf("keywords.txt = %q, %v", data, err) + } + + if err := os.WriteFile(filepath.Join("metadata", "en-US", "keywords.txt"), []byte("one,two,three\n"), 0o644); err != nil { + t.Fatal(err) + } + stdout, stderr, err = run(t, "ios", "metadata", "push", "--bundle-id", "com.example.app") + if err == nil || !strings.Contains(err.Error(), "--yes") { + t.Fatalf("push without --yes: %v", err) + } + if !strings.Contains(stdout+stderr, `Will update en-US keywords: "one,two" → "one,two,three"`) { + t.Errorf("plan not printed:\n%s%s", stdout, stderr) + } + stdout, _, err = run(t, "ios", "metadata", "push", "--bundle-id", "com.example.app", "--dry-run", "--json") + if err != nil { + t.Fatal(err) + } + var plan metadata.Plan + if err := json.Unmarshal([]byte(stdout), &plan); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout) + } + if len(plan.Changes) != 1 || plan.Changes[0].Field != "keywords" || plan.Applied { + t.Errorf("plan = %+v", plan) + } + if w := f.written(); len(w) != 0 { + t.Fatalf("dry run wrote: %v", w) + } + + if _, stderr, err = run(t, "ios", "metadata", "push", "--bundle-id", "com.example.app", "--yes"); err != nil { + t.Fatalf("%v\n%s", err, stderr) + } + if w := f.written(); len(w) != 1 || w[0] != "PATCH /v1/appStoreVersionLocalizations/vl-1" { + t.Errorf("writes = %v", w) + } + f.mu.Lock() + body := f.bodies["PATCH /v1/appStoreVersionLocalizations/vl-1"] + f.mu.Unlock() + attrs := obj(t, body, "data", "attributes") + if len(attrs) != 1 || attrs["keywords"] != "one,two,three" { + t.Errorf("patch = %v", attrs) + } +} + +func TestMetadataPushValidatesLocally(t *testing.T) { + f := newMetadataFake(t) + t.Chdir(t.TempDir()) + if err := os.MkdirAll(filepath.Join("meta", "en-US"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("meta", "en-US", "subtitle.txt"), []byte(strings.Repeat("s", 31)), 0o644); err != nil { + t.Fatal(err) + } + _, _, err := run(t, "ios", "metadata", "push", "--bundle-id", "com.example.app", "--metadata-dir", "meta", "--yes") + if err == nil || !strings.Contains(err.Error(), "en-US/subtitle.txt is 31 characters, the limit is 30") { + t.Errorf("err = %v", err) + } + if w := f.written(); len(w) != 0 { + t.Errorf("writes = %v", w) + } +} From 7ed68674aec001c9764e75c81e86c1c935662d9d Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:50 +0200 Subject: [PATCH 4/5] pkg: expose metadata and the asc localization and screenshot types --- pkg/asc/asc.go | 23 +++++++++++++++ pkg/metadata/metadata.go | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 pkg/metadata/metadata.go diff --git a/pkg/asc/asc.go b/pkg/asc/asc.go index c6ab5c8..6ded245 100644 --- a/pkg/asc/asc.go +++ b/pkg/asc/asc.go @@ -24,6 +24,9 @@ type ( ErrorSource = asc.ErrorSource App = asc.App + AppInfo = asc.AppInfo + AppScreenshot = asc.AppScreenshot + AppScreenshotSet = asc.AppScreenshotSet AppStoreVersion = asc.AppStoreVersion AppStoreVersionUpdate = asc.AppStoreVersionUpdate BetaAppReviewSubmission = asc.BetaAppReviewSubmission @@ -40,9 +43,11 @@ type ( BundleID = asc.BundleID Certificate = asc.Certificate Device = asc.Device + Localization = asc.Localization Profile = asc.Profile ReviewSubmission = asc.ReviewSubmission ReviewSubmissionItem = asc.ReviewSubmissionItem + ScreenshotFailedError = asc.ScreenshotFailedError StateDetail = asc.StateDetail UploadBuildOptions = asc.UploadBuildOptions UploadFailedError = asc.UploadFailedError @@ -91,6 +96,21 @@ const ( VersionStateWaitingForReview = asc.VersionStateWaitingForReview VersionStateInReview = asc.VersionStateInReview + AssetStateAwaitingUpload = asc.AssetStateAwaitingUpload + AssetStateUploadComplete = asc.AssetStateUploadComplete + AssetStateComplete = asc.AssetStateComplete + AssetStateFailed = asc.AssetStateFailed + + AttrDescription = asc.AttrDescription + AttrKeywords = asc.AttrKeywords + AttrWhatsNew = asc.AttrWhatsNew + AttrPromotionalText = asc.AttrPromotionalText + AttrMarketingURL = asc.AttrMarketingURL + AttrSupportURL = asc.AttrSupportURL + AttrName = asc.AttrName + AttrSubtitle = asc.AttrSubtitle + AttrPrivacyPolicyURL = asc.AttrPrivacyPolicyURL + RoleCustomerSupport = asc.RoleCustomerSupport CodeNoInstallableBuilds = asc.CodeNoInstallableBuilds ) @@ -116,5 +136,8 @@ func MatchBetaGroup(groups []BetaGroup, name string) (*BetaGroup, error) { // HasCode reports whether err is an App Store Connect error carrying code. func HasCode(err error, code string) bool { return asc.HasCode(err, code) } +// FileMD5 returns the MD5 of a file in hex, the form sourceFileChecksum uses. +func FileMD5(path string) (string, error) { return asc.FileMD5(path) } + // IsStatus reports whether err is an App Store Connect error with the HTTP status. func IsStatus(err error, status int) bool { return asc.IsStatus(err, status) } diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go new file mode 100644 index 0000000..9316c3f --- /dev/null +++ b/pkg/metadata/metadata.go @@ -0,0 +1,60 @@ +// Package metadata exposes the App Store listing sync behind `builder ios +// metadata pull` and `push` to code outside this module: fastlane +// deliver-style files (metadata//.txt, category files, +// screenshots//) against an app's editable App Store version. +package metadata + +import ( + "context" + + "github.com/MobAI-App/ios-builder/internal/metadata" + "github.com/MobAI-App/ios-builder/pkg/asc" +) + +type ( + Options = metadata.Options + AppRef = metadata.AppRef + VersionRef = metadata.VersionRef + Field = metadata.Field + Local = metadata.Local + LocalScreenshots = metadata.LocalScreenshots + Plan = metadata.Plan + Change = metadata.Change + PullResult = metadata.PullResult +) + +const ( + ChangeCreateVersion = metadata.ChangeCreateVersion + ChangeCreateLocale = metadata.ChangeCreateLocale + ChangeUpdate = metadata.ChangeUpdate + ChangeCategory = metadata.ChangeCategory + ChangeScreenshots = metadata.ChangeScreenshots + + MaxScreenshotsPerSet = metadata.MaxScreenshotsPerSet +) + +// Fields lists every localized field with its file name, attribute and limit. +var Fields = metadata.Fields + +// Pull writes the App Store listing into the metadata (and screenshots) directory. +func Pull(ctx context.Context, client *asc.Client, opts *Options) (*PullResult, error) { + return metadata.Pull(ctx, client, opts) +} + +// NewPlan validates the local files and diffs them against App Store +// Connect without writing; Plan.Apply writes the differences. +func NewPlan(ctx context.Context, client *asc.Client, opts *Options) (*Plan, error) { + return metadata.NewPlan(ctx, client, opts) +} + +// LoadLocal reads a metadata directory. +func LoadLocal(dir string) (*Local, error) { return metadata.LoadLocal(dir) } + +// LoadScreenshots reads a screenshots directory, inferring display types +// from pixel sizes. +func LoadScreenshots(dir string) (LocalScreenshots, []string, error) { + return metadata.LoadScreenshots(dir) +} + +// DisplayTypeForSize infers a screenshot display type from its pixel size. +func DisplayTypeForSize(width, height int) string { return metadata.DisplayTypeForSize(width, height) } From 8129d96f609c5a4a3521da57c38aeadf6a8eaae0 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:47:00 +0200 Subject: [PATCH 5/5] docs: App Store metadata pull and push --- CLAUDE.md | 33 ++++++++++++++++++++- README.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c2b1e1..947abbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ go install ./cmd/builder ./builder ios release --profile store --group --notes # Build with the next build number, upload, wait, TestFlight ./builder ios release --profile store --app-store --release after-approval # Same, then App Review ./builder ios build --profile store --submit # Short for: ios release (no groups) +./builder ios metadata pull [--screenshots] [--clean] # App Store listing → metadata//*.txt (fastlane layout) +./builder ios metadata push --dry-run|--yes [--version X.Y] [--screenshots [--replace-screenshots]] # push what differs ./builder ios build --profile development --distribute # Build, then print an over-the-air install link + QR code ./builder ios distribute [--ipa x.ipa] [--once] [--json] # Same for an existing IPA; --cleanup removes leftovers ./builder asc apps|builds|groups|testers|users # App Store Connect listings (--json) @@ -182,6 +184,21 @@ builder ios release ─────► Preflight: API key; --profile/defaultProf ▼ distribute.Upload (wait) → SubmitTestFlight | SubmitAppStore +builder ios metadata ────► LoadLocal + Validate (lengths, URLs, categories) and LoadScreenshots + (display type from pixel size or a / subfolder) before any request + │ + ▼ + resolveTarget: editable appStoreVersion (or --version, created on push) + + editable appInfo (include=primaryCategory,secondaryCategory) + │ + ▼ + pull: appInfoLocalizations / appStoreVersionLocalizations → metadata//*.txt; + --screenshots: appScreenshotSets → appScreenshots → imageAsset templateUrl download + push: diff → "Will ..." plan → --yes: [POST appStoreVersions → replan] → + POST/PATCH appInfoLocalizations → POST/PATCH appStoreVersionLocalizations → + PATCH appInfos relationships → POST appScreenshotSets → DELETE (replace) → + POST appScreenshots → PUT chunks → PATCH uploaded + MD5 → poll assetDeliveryState + builder ios distribute ──► otainstall.Inspect: Info.plist + embedded.mobileprovision (unsigned / App Store profile → error naming the alternative) │ @@ -213,6 +230,7 @@ internal/ asc/ # App Store Connect API client (JWT, JSON:API, apps, builds, uploads, TestFlight, # beta groups, beta testers, team users/invitations, review) distribute/ # Upload / TestFlight / App Store / tester flows on top of asc + metadata/ # ios metadata pull/push: fastlane-layout files ↔ version/app-info localizations, categories, screenshots ipa/ # Info.plist and embedded.mobileprovision reading from .ipa archives otainstall/ # ios distribute: over-the-air install links (manifest, QR, GitHub draft release + gist backend) build/ # Build coordination (snapshot + trigger + poll + download) @@ -402,7 +420,7 @@ internal/ with the longest covering entry or fails naming the ids to add; `write_export_options` exports them - **Extension Points**: `ios release` composes `distribute.Upload` and `distribute.SubmitTestFlight`. `pkg/asc`, `pkg/distribute`, `pkg/release`, - `pkg/signing` and `pkg/ipa` alias the internal packages so another program + `pkg/signing`, `pkg/ipa` and `pkg/metadata` alias the internal packages so another program (mobai-dev) can drive the same flows; `release.Builder` is the one-method interface a foreign build backend implements. - **OTA Install, Not OTA Updates** (`internal/otainstall`): `ios distribute` serves a whole signed IPA @@ -428,6 +446,19 @@ internal/ - **QR Rendering**: `skip2/go-qrcode` at error-correction Low, Unicode half blocks (two module rows per line, 2-module quiet zone), light modules as `█` so it scans on a dark terminal (`--qr-invert` for light); `TestQRFitsATerminal` pins a representative link at 41 modules (version 6). Printed only on a TTY or `--qr`. +- **App Store Metadata** (`internal/metadata`): fastlane deliver layout (`metadata//.txt`, + `{primary,secondary}_category.txt`, `screenshots//[/]`); `Fields` is the one table of + file → attribute → resource → limit. `NewPlan` validates everything locally before the first request, then + diffs; `Apply` writes app info locales, version locales, categories, screenshots in that order. Text is + compared after CRLF → LF and TrimSpace on both sides. A field without a file is untouched; an empty file clears. +- **Metadata Version Targeting**: no `--version` = the `Editable()` version (pull falls back to the newest); + `--version` that does not exist is created by push, which then re-resolves and recomputes the plan, since + App Store Connect copies the previous version's localizations (and opens a new editable appInfo) on create. + App-info changes on a non-editable appInfo fail at plan time. +- **Screenshot Diff**: by `sourceFileChecksum` (MD5 hex) per locale + display type. Default appends the files a + set lacks (refusing past 10); `--replace-screenshots` deletes and re-uploads a set whose ordered checksums + differ. Pull names files `NN_` (an existing `NN_` prefix replaced) under the display-type subfolder, so + a pulled tree pushes back as an empty plan. Pixel sizes two types share go to the newer type. - **Signing Sets As A Library**: `signing.Setup` and `signing.EnsureSecrets` (internal/signing/sets.go) hold the non-interactive core of `signing setup` and on-demand provisioning; cmd/builder keeps the prompts, the plan and the diff --git a/README.md b/README.md index cc24fe8..cf43b3d 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,8 @@ builder ios build --profile store --submit # Short for: ios release (TestFlig builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing builder ios submit --testflight --group "Beta Testers" --notes "What to test" builder ios submit --app-store --release after-approval # Submit the version for App Review +builder ios metadata pull # App Store listing into ./metadata (fastlane deliver layout) +builder ios metadata push --yes # Push what differs (--dry-run for the plan; --screenshots for images) # Install on a device over the air (development, ad-hoc or enterprise build) builder ios build --profile development --distribute # Build, then print an install link + QR code @@ -273,7 +275,7 @@ builder asc users # Team members and whether they can test internall builder asc users invite dev@example.com --role DEVELOPER --first Dee --last Vee ``` -Every `release`/`upload`/`submit`/`distribute`/`asc` command takes `--json` for +Every `release`/`upload`/`submit`/`metadata`/`distribute`/`asc` command takes `--json` for machine-readable output and never prompts, so agents and CI jobs can drive them. ## Configuration @@ -832,6 +834,89 @@ the tester from TestFlight team-wide), print what goes and then need `--yes`. Group names match case-insensitively; when two differ only by case, the command refuses and lists both. +## App Store metadata + +`builder ios metadata pull` and `push` keep the App Store listing in files, +through the App Store Connect API, from any platform. The layout is fastlane +deliver's, so an existing `fastlane/metadata` and `fastlane/screenshots` work +with `--metadata-dir` and `--screenshots-dir`: + +``` +metadata/ + primary_category.txt PRODUCTIVITY (App Store Connect IDs; MZGenre.* accepted) + secondary_category.txt + en-US/ + name.txt 30 characters (app info) + subtitle.txt 30 (app info) + privacy_url.txt (app info) + description.txt 4000 + keywords.txt 100 + release_notes.txt 4000 (What's New) + promotional_text.txt 170 + marketing_url.txt + support_url.txt +screenshots/ + en-US/ + 01_home.png display type from the pixel size + APP_IPAD_PRO_129/home.png or explicit: a subfolder named after it +``` + +```bash +builder ios metadata pull # text and categories of the version being prepared +builder ios metadata pull --screenshots # also download screenshots/// +builder ios metadata push --dry-run # print what would change +builder ios metadata push --yes # change it +builder ios metadata push --version 1.3 --yes # target (or create) version 1.3 +builder ios metadata push --screenshots --replace-screenshots --yes +``` + +- **Version.** Both commands work on the App Store version being prepared + (`PREPARE_FOR_SUBMISSION`, or rejected); pull falls back to the newest + version when none is. `--version X.Y` picks one, and push creates it when it + does not exist, then compares against the localizations App Store Connect + copied into it. Name, subtitle, privacy URL and categories live on the app + info, which is only editable while a version is being prepared. +- **Push changes only what differs.** It prints one `Will ...` line per + difference and needs `--yes`; `--dry-run` prints the plan and exits 0. A + field without a file is left alone, an empty file clears it, and a new + locale directory adds that language (a new language needs `name.txt`). + Lengths, URLs, categories, screenshot sizes and the 10-per-set limit are + checked before anything is sent. `review_information/` and `default/` are ignored. +- **Pull never deletes** local files App Store Connect has no value for unless + `--clean`; empty fields get no file. Screenshots download only with + `--screenshots`. +- **Screenshots** are compared by MD5 per locale and display type. Without + `--replace-screenshots` push appends the local images a set lacks; with it, + a set that differs is emptied and uploaded again in file-name order. Each + upload is reserved, sent in the chunks App Store Connect hands out, + committed with its checksum and followed until processed. + +Display types inferred from the pixel size (portrait or landscape): + +| Size | Display type | +|------|--------------| +| 1320x2868, 1290x2796, 1260x2736 | `APP_IPHONE_67` (6.9"/6.7") | +| 1284x2778, 1242x2688 | `APP_IPHONE_65` | +| 1206x2622, 1179x2556, 1170x2532 | `APP_IPHONE_61` (6.3"/6.1") | +| 1125x2436, 1080x2340 | `APP_IPHONE_58` | +| 1242x2208 | `APP_IPHONE_55` | +| 750x1334 | `APP_IPHONE_47` | +| 640x1136 | `APP_IPHONE_40` | +| 640x960 | `APP_IPHONE_35` | +| 2064x2752, 2048x2732 | `APP_IPAD_PRO_3GEN_129` (13"/12.9") | +| 1668x2420, 1668x2388, 1640x2360, 1488x2266 | `APP_IPAD_PRO_3GEN_11` | +| 1668x2224 | `APP_IPAD_105` | +| 1536x2048 | `APP_IPAD_97` | +| 1280x800, 1440x900, 2560x1600, 2880x1800 | `APP_DESKTOP` | + +2048x2732 is also the size of the older 12.9" iPad Pro (`APP_IPAD_PRO_129`): +put those in a `APP_IPAD_PRO_129/` subfolder. Any other size, Apple TV, Watch, +Vision Pro and iMessage screenshots need the subfolder too; other subfolders +(fastlane's `iMessage/`) are skipped with a warning. + +Not covered yet: copyright, review information, age rating, subcategories, +app previews (video) and screenshot reordering within a set. + ## Install on a device (internal distribution) ```bash