From 8f6c49e86fbb7b790e542b5c43e4b9148d428587 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 16:17:34 +0530 Subject: [PATCH 01/12] feat(cli): register App IDs with Push Notifications in the appwrite APNs setup APNs refuses pushes for a bundle ID (TopicDisallowed) unless the team has it registered as an App ID with Push Notifications, and creating a key does not register it. The appwrite adapter now implements apns.AppIDPreparer: with the same Apple session it looks the bundle ID up through the portal's services-account/v1 bundleIds API, registers it as an explicit iOS App ID when missing and turns on Push Notifications when off, asking first unless told not to. The requests follow fastlane spaceship's web-session calls. --- src/SDK/Language/CLI.php | 5 + templates/cli/internal/apns/apns.go | 30 +++ .../cli/internal/apns/apnstest/apnstest.go | 22 ++ .../cli/internal/apns/appwrite/adapter.go | 69 ++++++ .../internal/apns/appwrite/adapter_test.go | 176 +++++++++++++ templates/cli/internal/apns/appwrite/appid.go | 234 ++++++++++++++++++ 6 files changed, 536 insertions(+) create mode 100644 templates/cli/internal/apns/appwrite/appid.go diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 88c835a47b..adaeecb6ac 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1171,6 +1171,11 @@ public function getFiles(): array 'destination' => 'internal/apns/appwrite/adapter_test.go', 'template' => 'cli/internal/apns/appwrite/adapter_test.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/appid.go', + 'template' => 'cli/internal/apns/appwrite/appid.go', + ], [ 'scope' => 'default', 'destination' => 'internal/apns/appwrite/auth.go', diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go index 34f6702a62..898095e9a6 100644 --- a/templates/cli/internal/apns/apns.go +++ b/templates/cli/internal/apns/apns.go @@ -115,6 +115,36 @@ type Adapter interface { CreateKey(ctx context.Context, request Request) (Key, error) } +// AppIDRequest names the App ID to prepare. +type AppIDRequest struct { + BundleID string + // Ask asks before registering the App ID or turning on Push + // Notifications. Without it the changes are made directly. + Ask bool +} + +// AppIDResult is what preparing an App ID found and changed. +type AppIDResult struct { + TeamID string + // Registered and Push describe the App ID after the call. + Registered bool + Push bool + // Created and PushTurnedOn are the changes the call made. + Created bool + PushTurnedOn bool +} + +// AppIDPreparer is an Adapter that can also prepare the app's App ID. APNs +// refuses pushes for a bundle ID (TopicDisallowed) unless the team has it +// registered as an App ID with Push Notifications, and a key alone does not +// register it. +type AppIDPreparer interface { + // PrepareAppID signs in like CreateKey, registers the bundle ID as an + // explicit App ID when the team has none, and turns on Push + // Notifications when it is off. + PrepareAppID(ctx context.Context, request Request, appID AppIDRequest) (AppIDResult, error) +} + var ( keyIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) teamIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) diff --git a/templates/cli/internal/apns/apnstest/apnstest.go b/templates/cli/internal/apns/apnstest/apnstest.go index 5f92d12ea9..490b414ed2 100644 --- a/templates/cli/internal/apns/apnstest/apnstest.go +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -60,3 +60,25 @@ func P8() string { return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) } + +// AppIDAdapter is an Adapter that also prepares App IDs: it records each +// call and returns Result, or AppIDErr when set. +type AppIDAdapter struct { + Adapter + Result apns.AppIDResult + AppIDErr error + AppIDRequests []apns.AppIDRequest + // AppIDSignIns are the sign-in requests each call came with. + AppIDSignIns []apns.Request +} + +// PrepareAppID records the call and returns Result or AppIDErr. +func (a *AppIDAdapter) PrepareAppID(_ context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + a.AppIDRequests = append(a.AppIDRequests, appID) + a.AppIDSignIns = append(a.AppIDSignIns, request) + if a.AppIDErr != nil { + return apns.AppIDResult{}, a.AppIDErr + } + + return a.Result, nil +} diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go index 651f515fe1..4df2739cd9 100644 --- a/templates/cli/internal/apns/appwrite/adapter.go +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -75,6 +75,75 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key return apns.Key{KeyID: keyID, TeamID: teamID, P8: p8}, nil } +// PrepareAppID signs in, then makes sure the bundle ID is an App ID with Push +// Notifications on the team, asking first when appID.Ask is set. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + portal, err := a.connect(ctx, request) + if err != nil { + return apns.AppIDResult{}, err + } + defer portal.saveSession() + + teamID, err := portal.chooseTeam(ctx, request.TeamID) + if err != nil { + return apns.AppIDResult{}, err + } + result := apns.AppIDResult{TeamID: teamID} + found, err := portal.appID(ctx, teamID, appID.BundleID) + if err != nil { + return result, err + } + if found == nil { + if appID.Ask { + register, err := confirm(request.Asker, fmt.Sprintf("%s is not registered as an App ID on team %s, so APNs refuses pushes for it. Register it with Push Notifications?", appID.BundleID, teamID)) + if err != nil || !register { + return result, err + } + } + if found, err = portal.createAppID(ctx, teamID, appID.BundleID, appIDName(appID.BundleID)); err != nil { + return result, err + } + result.Created = true + } + result.Registered = true + if !found.Push { + // Registering it was already agreed to as "with Push Notifications". + if appID.Ask && !result.Created { + turnOn, err := confirm(request.Asker, fmt.Sprintf("Push Notifications is off for %s, so APNs refuses pushes for it. Turn it on?", appID.BundleID)) + if err != nil || !turnOn { + return result, err + } + } + if err := portal.enablePush(ctx, teamID, found); err != nil { + return result, err + } + result.PushTurnedOn = true + } + result.Push = found.Push + + return result, nil +} + +// appIDName is the App ID's description in the portal, which allows only +// letters, digits and spaces. +func appIDName(bundleID string) string { + name := strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return r + } + + return ' ' + }, bundleID) + + return strings.Join(strings.Fields(name), " ") +} + +func confirm(asker apns.Asker, question string) (bool, error) { + choice, err := asker.Choose(question, []string{"Yes", "No"}) + + return choice == 0, err +} + func sessionPath(dir string) string { if dir == "" { return "" diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index 8128e8be58..0768488961 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -58,6 +58,13 @@ type fakeApple struct { clientA []byte verifier *big.Int created map[string]map[string]any + // appIDs are the team's App IDs by identifier. + appIDs map[string]*fakeAppID +} + +type fakeAppID struct { + id string + push bool } func newFakeApple(t *testing.T) *fakeApple { @@ -66,6 +73,7 @@ func newFakeApple(t *testing.T) *fakeApple { teams: []Team{{ID: "ABCDE12345", Name: "Example Inc", Type: "Company/Organization"}}, salt: []byte("0123456789abcdef"), created: map[string]map[string]any{}, + appIDs: map[string]*fakeAppID{}, } } @@ -241,6 +249,14 @@ func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { } f.portal(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/QH65B2/"), params) + case strings.HasPrefix(r.URL.Path, "/services-account/v1/"): + if !f.signedIn(r) { + w.WriteHeader(http.StatusUnauthorized) + + return + } + f.connectAPI(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/v1/"), params) + default: f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) @@ -305,6 +321,72 @@ func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, } } +// connectAPI plays the portal's App ID API as a web session uses it: reads +// are POSTs with X-HTTP-Method-Override: GET and the query in the body. +func (f *fakeApple) connectAPI(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { + if r.Header.Get("Content-Type") != "application/vnd.api+json" || r.Header.Get("X-Requested-With") != "XMLHttpRequest" { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + switch { + case path == "bundleIds" && r.Method == http.MethodPost && r.Header.Get("X-HTTP-Method-Override") == http.MethodGet: + query, _ := url.ParseQuery(fmt.Sprint(params["urlEncodedQueryParams"])) + if params["teamId"] != "ABCDE12345" || query.Get("include") != "bundleIdCapabilities" { + f.t.Errorf("list App IDs params = %v", params) + } + data := []map[string]any{} + included := []map[string]any{} + for identifier, appID := range f.appIDs { + // Like Apple, the filter matches by prefix. + if !strings.HasPrefix(identifier, query.Get("filter[identifier]")) { + continue + } + capabilities := []map[string]any{{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE"}} + included = append(included, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE", "attributes": map[string]any{"capabilityType": "IN_APP_PURCHASE"}}) + if appID.push { + capabilities = append(capabilities, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_PUSH_NOTIFICATIONS"}) + included = append(included, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_PUSH_NOTIFICATIONS", "attributes": map[string]any{"capabilityType": "PUSH_NOTIFICATIONS"}}) + } + data = append(data, map[string]any{ + "type": "bundleIds", + "id": appID.id, + "attributes": map[string]any{"identifier": identifier, "seedId": "ABCDE12345", "platform": "IOS"}, + "relationships": map[string]any{"bundleIdCapabilities": map[string]any{"data": capabilities}}, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"data": data, "included": included}) + case path == "bundleIds" && r.Method == http.MethodPost: + data, _ := params["data"].(map[string]any) + attributes, _ := data["attributes"].(map[string]any) + identifier, _ := attributes["identifier"].(string) + name, _ := attributes["name"].(string) + if attributes["teamId"] != "ABCDE12345" || attributes["platform"] != "IOS" || identifier == "" || strings.ContainsAny(name, ".-_") { + writeJSON(w, http.StatusConflict, map[string]any{"errors": []map[string]any{{"status": "409", "detail": "An attribute value is invalid."}}}) + + return + } + appID := &fakeAppID{id: "APPID" + fmt.Sprint(len(f.appIDs)+1)} + f.appIDs[identifier] = appID + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"type": "bundleIds", "id": appID.id, "attributes": attributes}}) + case strings.HasPrefix(path, "bundleIds/") && r.Method == http.MethodPatch: + encoded, _ := json.Marshal(params) + for _, appID := range f.appIDs { + if path == "bundleIds/"+appID.id && strings.Contains(string(encoded), `"id":"PUSH_NOTIFICATIONS"`) && strings.Contains(string(encoded), `"enabled":true`) && strings.Contains(string(encoded), `"teamId":"ABCDE12345"`) { + appID.push = true + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"type": "bundleIds", "id": appID.id}}) + + return + } + } + f.t.Errorf("unexpected App ID update %s %s", path, encoded) + w.WriteHeader(http.StatusNotFound) + default: + f.t.Errorf("unexpected App ID call %s %s", r.Method, path) + w.WriteHeader(http.StatusNotFound) + } +} + type scriptedAsker struct { answers map[string][]string choices map[string]int @@ -692,4 +774,98 @@ func TestResetSignsInAgain(t *testing.T) { t.Errorf("signed in again after a reset: %v", fake.requests) } } + +// prepareAppID signs in with the saved session of a first run, then prepares +// com.example.app on the fake. +func prepareAppID(t *testing.T, fake *fakeApple, asker *scriptedAsker, ask bool) (apns.AppIDResult, error) { + t.Helper() + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + client, _ := newTestClient(t, fake, asker, dir, env) + client.request.TeamID = "ABCDE12345" + fake.requests = nil + + return client.adapter.PrepareAppID(context.Background(), client.request, apns.AppIDRequest{BundleID: "com.example.app", Ask: ask}) +} + +const registerQuestion = "com.example.app is not registered as an App ID on team ABCDE12345, so APNs refuses pushes for it. Register it with Push Notifications?" + +func TestPrepareAppIDRegistersAMissingAppIDWithPush(t *testing.T) { + fake := newFakeApple(t) + // Only a longer identifier exists, which the portal's prefix filter also returns. + fake.appIDs["com.example.app.widget"] = &fakeAppID{id: "WIDGET", push: true} + asker := &scriptedAsker{choices: map[string]int{registerQuestion: 0}} + + result, err := prepareAppID(t, fake, asker, true) + if err != nil { + t.Fatal(err) + } + if !result.Created || !result.PushTurnedOn || !result.Registered || !result.Push || result.TeamID != "ABCDE12345" { + t.Errorf("result = %+v", result) + } + appID := fake.appIDs["com.example.app"] + if appID == nil || !appID.push { + t.Fatalf("App ID = %+v", appID) + } + // One question covers registering it with push. + if strings.Join(asker.asked, "|") != registerQuestion { + t.Errorf("asked %v", asker.asked) + } +} + +func TestPrepareAppIDTurnsOnPush(t *testing.T) { + fake := newFakeApple(t) + fake.appIDs["com.example.app"] = &fakeAppID{id: "EXISTING"} + asker := &scriptedAsker{} + + result, err := prepareAppID(t, fake, asker, false) + if err != nil { + t.Fatal(err) + } + if result.Created || !result.PushTurnedOn || !result.Push || !fake.appIDs["com.example.app"].push { + t.Errorf("result = %+v", result) + } + if len(asker.asked) != 0 || contains(fake.requests, "POST /services-account/v1/bundleIds/EXISTING") { + t.Errorf("asked %v, requests %v", asker.asked, fake.requests) + } +} + +func TestPrepareAppIDLeavesAReadyAppIDAlone(t *testing.T) { + fake := newFakeApple(t) + fake.appIDs["com.example.app"] = &fakeAppID{id: "READY", push: true} + + result, err := prepareAppID(t, fake, &scriptedAsker{}, true) + if err != nil { + t.Fatal(err) + } + if result.Created || result.PushTurnedOn || !result.Registered || !result.Push { + t.Errorf("result = %+v", result) + } + if contains(fake.requests, "PATCH /services-account/v1/bundleIds/READY") { + t.Errorf("changed a ready App ID: %v", fake.requests) + } +} + +func TestPrepareAppIDChangesNothingWhenDeclined(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{choices: map[string]int{registerQuestion: 1}} + + result, err := prepareAppID(t, fake, asker, true) + if err != nil { + t.Fatal(err) + } + if result.Registered || result.Created || len(fake.appIDs) != 0 { + t.Errorf("result = %+v, App IDs %v", result, fake.appIDs) + } +} + +func TestAppIDNameKeepsLettersAndDigits(t *testing.T) { + if name := appIDName("io.appwrite.push-test_2"); name != "io appwrite push test 2" { + t.Errorf("name = %q", name) + } +} {% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/appid.go b/templates/cli/internal/apns/appwrite/appid.go new file mode 100644 index 0000000000..86ef6a0ed5 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/appid.go @@ -0,0 +1,234 @@ +package appwrite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" +) +{% verbatim %} +// App IDs live behind the portal's App Store Connect style API under +// services-account/v1, which takes the same cookie session as the key calls. +// With a web session, as fastlane's spaceship uses it, reads are POSTs that +// carry X-HTTP-Method-Override: GET and the query in the body, and every +// write carries the team in its attributes. + +const pushCapability = "PUSH_NOTIFICATIONS" + +// AppID is a registered App ID and whether it can receive pushes. +type AppID struct { + ID string + Identifier string + SeedID string + Push bool +} + +type jsonAPIResource struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes json.RawMessage `json:"attributes"` + Relationships map[string]struct { + Data json.RawMessage `json:"data"` + } `json:"relationships"` +} + +type jsonAPIDocument struct { + Data json.RawMessage `json:"data"` + Included []jsonAPIResource `json:"included"` + Errors []struct { + Status string `json:"status"` + Code string `json:"code"` + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` +} + +// connectAPI calls services-account/v1/. A GET goes out as a POST with +// the query in the body; other methods send body as JSON:API. +func (p *portalClient) connectAPI(ctx context.Context, method, path, teamID string, query url.Values, body map[string]any) (*jsonAPIDocument, error) { + target := p.hosts.Portal + "/services-account/v1/" + path + headers := map[string]string{ + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/vnd.api+json", + "X-Requested-With": "XMLHttpRequest", + } + if p.csrf != "" || p.csrfTS != "" { + headers["csrf"] = p.csrf + headers["csrf_ts"] = p.csrfTS + } + var payload any + switch method { + case http.MethodGet: + headers["X-HTTP-Method-Override"] = http.MethodGet + method = http.MethodPost + payload = map[string]any{"urlEncodedQueryParams": query.Encode(), "teamId": teamID} + default: + payload = body + } + + response, raw, err := p.request(ctx, method, target, payload, headers) + if err != nil { + return nil, fmt.Errorf("could not reach the Apple Developer portal: %w", err) + } + if token := response.Header.Get("csrf"); token != "" { + p.csrf, p.csrfTS = token, response.Header.Get("csrf_ts") + } + if response.StatusCode == http.StatusUnauthorized { + return nil, errSessionExpired + } + var document jsonAPIDocument + if len(raw) > 0 { + if err := json.Unmarshal(raw, &document); err != nil && response.StatusCode < 300 { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable response: %w", err) + } + } + if response.StatusCode >= 300 { + for _, problem := range document.Errors { + if detail := strings.TrimSpace(problem.Detail); detail != "" { + return nil, fmt.Errorf("the Apple Developer portal refused the request: %s", detail) + } + } + + return nil, fmt.Errorf("the Apple Developer portal returned HTTP %d for %s", response.StatusCode, path) + } + + return &document, nil +} + +// appID looks up identifier among the team's App IDs. It returns nil when +// the team has none with that identifier. +func (p *portalClient) appID(ctx context.Context, teamID, identifier string) (*AppID, error) { + query := url.Values{} + query.Set("filter[identifier]", identifier) + query.Set("include", "bundleIdCapabilities") + query.Set("limit", "200") + document, err := p.connectAPI(ctx, http.MethodGet, "bundleIds", teamID, query, nil) + if err != nil { + return nil, err + } + var resources []jsonAPIResource + if err := json.Unmarshal(document.Data, &resources); err != nil { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable App ID list: %w", err) + } + for _, resource := range resources { + var attributes struct { + Identifier string `json:"identifier"` + SeedID string `json:"seedId"` + } + if err := json.Unmarshal(resource.Attributes, &attributes); err != nil { + continue + } + // The filter also matches by prefix: only the exact identifier counts. + if attributes.Identifier != identifier { + continue + } + + return &AppID{ + ID: resource.ID, + Identifier: attributes.Identifier, + SeedID: attributes.SeedID, + Push: hasPush(resource, document.Included), + }, nil + } + + return nil, nil +} + +// hasPush reports whether the App ID has the Push Notifications capability +// among its included capabilities. +func hasPush(appID jsonAPIResource, included []jsonAPIResource) bool { + var linked []struct { + ID string `json:"id"` + } + if relationship, ok := appID.Relationships["bundleIdCapabilities"]; ok { + _ = json.Unmarshal(relationship.Data, &linked) + } + ids := map[string]bool{} + for _, capability := range linked { + ids[capability.ID] = true + } + for _, resource := range included { + if resource.Type != "bundleIdCapabilities" || !ids[resource.ID] { + continue + } + var attributes struct { + CapabilityType string `json:"capabilityType"` + Enabled *bool `json:"enabled"` + } + if err := json.Unmarshal(resource.Attributes, &attributes); err != nil { + continue + } + if attributes.CapabilityType == pushCapability && (attributes.Enabled == nil || *attributes.Enabled) { + return true + } + } + + return false +} + +// createAppID registers identifier as an explicit iOS App ID. +func (p *portalClient) createAppID(ctx context.Context, teamID, identifier, name string) (*AppID, error) { + document, err := p.connectAPI(ctx, http.MethodPost, "bundleIds", teamID, nil, map[string]any{ + "data": map[string]any{ + "type": "bundleIds", + "attributes": map[string]any{ + "identifier": identifier, + "name": name, + "platform": "IOS", + "seedId": teamID, + "teamId": teamID, + }, + }, + }) + if err != nil { + return nil, err + } + var resource jsonAPIResource + if err := json.Unmarshal(document.Data, &resource); err != nil || resource.ID == "" { + return nil, errors.New("the Apple Developer portal registered no App ID") + } + + return &AppID{ID: resource.ID, Identifier: identifier, SeedID: teamID}, nil +} + +// enablePush turns on the Push Notifications capability of an App ID. +func (p *portalClient) enablePush(ctx context.Context, teamID string, appID *AppID) error { + seedID := appID.SeedID + if seedID == "" { + seedID = teamID + } + _, err := p.connectAPI(ctx, http.MethodPatch, "bundleIds/"+appID.ID, teamID, nil, map[string]any{ + "data": map[string]any{ + "type": "bundleIds", + "id": appID.ID, + "attributes": map[string]any{ + "permissions": map[string]bool{"edit": true, "delete": true}, + "seedId": seedID, + "teamId": teamID, + }, + "relationships": map[string]any{ + "bundleIdCapabilities": map[string]any{ + "data": []map[string]any{{ + "type": "bundleIdCapabilities", + "attributes": map[string]any{"enabled": true, "settings": []any{}}, + "relationships": map[string]any{ + "capability": map[string]any{ + "data": map[string]string{"type": "capabilities", "id": pushCapability}, + }, + }, + }}, + }, + }, + }, + }) + if err != nil { + return err + } + appID.Push = true + + return nil +} +{% endverbatim %} \ No newline at end of file From f23cd73eccd6abd1b051a3cb3d315db2095600a7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 16:17:34 +0530 Subject: [PATCH 02/12] feat(cli): check the App ID in init apns, and add --register-app-id After creating a key, init apns reuses the fresh Apple sign-in to check the bundle ID's App ID and offers to register it or turn on Push Notifications. --register-app-id runs the step on its own, also for a project that is already set up, makes the changes without asking and fails the command when it cannot. A setup that cannot check App IDs only warns. --- templates/cli/internal/cmd/initpush.go | 124 +++++++++++++++++--- templates/cli/internal/cmd/initpush_test.go | 92 +++++++++++++++ 2 files changed, 200 insertions(+), 16 deletions(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index bd660acf50..a3959c1cb7 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -37,6 +37,7 @@ import ( const ( apnsKeyPage = "https://developer.apple.com/account/resources/authkeys/add" apnsKeysListPage = "https://developer.apple.com/account/resources/authkeys/list" + appIDsPage = "https://developer.apple.com/account/resources/identifiers/list" fcmConsoleHome = "https://console.firebase.google.com" fcmServiceAccounts = "https://console.firebase.google.com/project/%s/settings/serviceaccounts/adminsdk" fcmSetupGuide = "https://firebase.google.com/docs/android/setup#add-config-file" @@ -59,6 +60,9 @@ type apnsOptions struct { reset bool environment string setup string + // registerAppID registers the bundle ID as an App ID with Push + // Notifications, without asking. + registerAppID bool } type fcmOptions struct { @@ -158,6 +162,7 @@ func newInitApnsCommand() *cobra.Command { flags.StringVar(&options.environment, "environment", string(apns.EnvironmentAll), "APNs environment to set up: production, sandbox or all") flags.StringVar(&options.setup, "provider", "", "How --create-key creates the key: appwrite, expo or fastlane. Defaults to $"+apnsSetupVariable+", then "+defaultApnsSetup) flags.BoolVar(&options.reset, "reset", false, "Sign in to Apple again instead of reusing the saved sign-in, and create the key (implies --create-key)") + flags.BoolVar(&options.registerAppID, "register-app-id", false, "Register the bundle ID as an App ID with Push Notifications in the Apple Developer portal, signing in with your Apple ID (experimental)") command.MarkFlagsMutuallyExclusive("create-key", "key-path") command.MarkFlagsMutuallyExclusive("reset", "key-path") command.MarkFlagsMutuallyExclusive("reset", "key-id") @@ -411,6 +416,9 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { output.Success(s.out, "APNs%s is already set up for %s.", environmentLabel(environment), bundleID) output.Hint(s.out, "Pass --force or --key-path to replace the key.") s.configureXcode(detected, bundleID) + if options.registerAppID { + return s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options) + } return nil } @@ -508,6 +516,11 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { s.configureXcode(detected, bundleID) output.Success(s.out, "APNs%s is set up for %s.", environmentLabel(environment), bundleID) + // Signed in to Apple already when a key was just created, so the App ID + // is checked too; --register-app-id asks for it on its own. + if createdKeyID != "" || options.registerAppID { + return s.prepareAppID(teamID, bundleID, options) + } return nil } @@ -655,22 +668,15 @@ func (a apnsAsker) Choose(question string, options []string) (int, error) { return strconv.Atoi(value) } -// createApnsKey creates a key through the Apple Developer portal and saves it -// into the project folder before anything else happens, because Apple lets a -// key be downloaded only once. -func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { - adapter, err := s.apnsAdapter(options.setup) - if err != nil { - return "", "", "", err - } +// appleRequest is how every Apple Developer portal step signs in: the Apple ID +// and password from the environment when set, the saved session unless reset. +func (s *pushSetup) appleRequest(adapter apns.Adapter, teamID string, reset bool) apns.Request { request := apns.Request{ - TeamID: teamID, - Name: "Appwrite Push " + time.Now().Format("20060102150405"), - Environment: environment, - AppleID: s.env(appleIDVariable), - Password: s.env(applePasswordVariable), - Reset: options.reset, - Asker: apnsAsker{s.prompter}, + TeamID: teamID, + AppleID: s.env(appleIDVariable), + Password: s.env(applePasswordVariable), + Reset: reset, + Asker: apnsAsker{s.prompter}, Log: func(format string, args ...any) { output.Log(s.out, format, args...) }, @@ -679,7 +685,7 @@ func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environmen request.SessionDir = filepath.Join(s.apnsSessions, adapter.Name()) } output.Log(s.out, "Signing in to the Apple Developer portal (%s).", adapter.Name()) - if options.reset { + if reset { output.Log(s.out, "Signing in again: the saved Apple sign-in is not used (--reset).") } if request.AppleID != "" { @@ -688,6 +694,92 @@ func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environmen if request.Password != "" { output.Log(s.out, "Using the Apple ID password from %s.", applePasswordVariable) } + + return request +} + +// prepareAppID makes sure the bundle ID is an App ID with Push Notifications +// on the team: APNs refuses pushes for any other bundle ID (TopicDisallowed), +// and Xcode registers it only for a device build with the capability added. +// After a new key it asks before each change; --register-app-id makes them +// directly and fails the command when it cannot. +func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions) error { + explicit := options.registerAppID + fail := func(format string, args ...any) error { + message := fmt.Sprintf(format, args...) + if explicit { + return errors.New(message) + } + output.Warn(s.out, "%s.", message) + output.Hint(s.out, "APNs accepts pushes only for a bundle ID registered as an App ID with Push Notifications. Check it at %s.", appIDsPage) + + return nil + } + adapter, err := s.apnsAdapter(options.setup) + if err != nil { + return fail("Could not check the App ID for %s: %s", bundleID, err) + } + preparer, ok := adapter.(apns.AppIDPreparer) + if !ok { + return fail("The %s setup cannot check App IDs, so %s was not checked", adapter.Name(), bundleID) + } + + output.Log(s.out, "Checking the App ID for %s ...", bundleID) + // A key created a moment ago left a fresh sign-in: --reset is not repeated. + request := s.appleRequest(adapter, teamID, options.reset && !options.createKey) + result, err := preparer.PrepareAppID(s.context(), request, apns.AppIDRequest{BundleID: bundleID, Ask: !explicit}) + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) { + return fail("Could not check the App ID for %s: %q needs an interactive terminal. Pass --register-app-id to register it without asking", bundleID, unanswered.Message) + } + if err != nil { + return fail("Could not check the App ID for %s: %s", bundleID, err) + } + + switch { + case result.Created: + output.Success(s.out, "Registered %s as an App ID with Push Notifications on team %s.", bundleID, result.TeamID) + case result.PushTurnedOn: + output.Success(s.out, "Turned on Push Notifications for the App ID %s.", bundleID) + case result.Registered && result.Push: + output.Success(s.out, "%s is registered as an App ID with Push Notifications.", bundleID) + case !result.Registered: + output.Warn(s.out, "%s is not registered as an App ID on team %s: APNs refuses pushes for it until it is.", bundleID, result.TeamID) + output.Hint(s.out, "Run `appwrite init apns --register-app-id`, or register it at %s.", appIDsPage) + default: + output.Warn(s.out, "Push Notifications is off for the App ID %s: APNs refuses pushes for it until it is on.", bundleID) + output.Hint(s.out, "Run `appwrite init apns --register-app-id`, or turn it on at %s.", appIDsPage) + } + + return nil +} + +// provisionedTeam is the team of an existing APNs provider for the bundle ID, +// else the one detected in the project, else none (signing in picks it). +func provisionedTeam(production, sandbox *messagingProvider, detected []string) string { + for _, provider := range []*messagingProvider{production, sandbox} { + if provider != nil && appleTeamID.MatchString(provider.credential("teamId")) { + return provider.credential("teamId") + } + } + if len(detected) == 1 { + return detected[0] + } + + return "" +} + +// createApnsKey creates a key through the Apple Developer portal and saves it +// into the project folder before anything else happens, because Apple lets a +// key be downloaded only once. +func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { + adapter, err := s.apnsAdapter(options.setup) + if err != nil { + return "", "", "", err + } + request := s.appleRequest(adapter, teamID, options.reset) + request.Name = "Appwrite Push " + time.Now().Format("20060102150405") + request.Environment = environment key, err := apns.CreateKey(s.context(), adapter, request) var unanswered *prompt.NonInteractiveError if errors.As(err, &unanswered) { diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 50a050081a..aa41174fc8 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -1719,3 +1719,95 @@ func TestInitApnsResetSignsInAgainAndCreatesAKey(t *testing.T) { t.Errorf("--reset with --key-path: %v", err) } } + +// useAppIDAdapter makes adapter, which can also prepare App IDs, the only APNs +// key setup. +func useAppIDAdapter(setup *pushSetup, adapter *apnstest.AppIDAdapter) *apnstest.AppIDAdapter { + adapter.AdapterName = defaultApnsSetup + if adapter.TeamID == "" { + adapter.TeamID = "APPLE12345" + } + setup.apnsAdapters = apns.NewRegistry(adapter) + + return adapter +} + +func TestInitApnsChecksTheAppIDAfterCreatingAKey(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + adapter := useAppIDAdapter(setup, &apnstest.AppIDAdapter{ + Adapter: apnstest.Adapter{KeyID: "NEWKEY1234"}, + Result: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, + }) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0] != (apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) { + t.Fatalf("App ID calls = %v", adapter.AppIDRequests) + } + // The key's sign-in just happened: --reset does not sign in a second time. + if signIn := adapter.AppIDSignIns[0]; signIn.TeamID != "ABCDE12345" || signIn.Reset { + t.Errorf("App ID sign-in = %+v", signIn) + } + if !strings.Contains(out.String(), "Registered com.example.app as an App ID with Push Notifications on team ABCDE12345.") { + t.Errorf("output:\n%s", out.String()) + } +} + +func TestInitApnsRegistersTheAppIDOfASetUpProject(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + adapter := useAppIDAdapter(setup, &apnstest.AppIDAdapter{ + Result: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, PushTurnedOn: true}, + }) + keyPath := testApnsKey(t, t.TempDir()) + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 0 { + t.Fatalf("checked the App ID without a sign-in to reuse: %v", adapter.AppIDRequests) + } + + // Already set up: --register-app-id runs on its own, without asking, + // on the team of the existing providers. + if err := setup.apns(apnsOptions{bundleID: "com.example.app", registerAppID: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0].Ask || adapter.AppIDSignIns[0].TeamID != "ABCDE12345" { + t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } + if !strings.Contains(out.String(), "Turned on Push Notifications for the App ID com.example.app.") { + t.Errorf("output:\n%s", out.String()) + } + + // Asked for explicitly, a failure fails the command. + adapter.AppIDErr = errors.New("could not reach Apple") + err := setup.apns(apnsOptions{bundleID: "com.example.app", registerAppID: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "Could not check the App ID for com.example.app: could not reach Apple") { + t.Errorf("err = %v", err) + } +} + +func TestInitApnsWarnsWhenTheSetupCannotCheckAppIDs(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || !strings.Contains(out.String(), "The appwrite setup cannot check App IDs, so com.example.app was not checked.") { + t.Errorf("providers = %v, output:\n%s", messaging.providers, out.String()) + } +} From b6aeeaded4159ebc8f698c919be4c8fcbef4f695 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 18:05:25 +0530 Subject: [PATCH 03/12] feat(cli): make PrepareAppID part of the APNs adapter interface Every APNs setup now prepares App IDs: PrepareAppID joins CreateKey on apns.Adapter, and apns.PrepareAppID holds adapters to the same contract as apns.CreateKey (a valid bundle and team ID in, a consistent result for the right team out). ConfirmAppIDChange and AppIDName give every adapter the same questions and App ID name. Request.SavedSignInOnly lets a check use a saved sign-in without asking, returning ErrSignInNeeded when there is none. --- templates/cli/internal/apns/apns.go | 84 +++++++++++++++--- templates/cli/internal/apns/apns_test.go | 86 +++++++++++++++++++ .../cli/internal/apns/apnstest/apnstest.go | 57 +++++++----- .../cli/internal/apns/appwrite/adapter.go | 29 ++----- .../internal/apns/appwrite/adapter_test.go | 15 +++- 5 files changed, 212 insertions(+), 59 deletions(-) diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go index 898095e9a6..5cb0a10bc6 100644 --- a/templates/cli/internal/apns/apns.go +++ b/templates/cli/internal/apns/apns.go @@ -73,6 +73,9 @@ var ( ErrUnavailable = errors.New("this APNs key setup is not available here") // ErrInvalidCredentials means Apple refused the Apple ID or password. ErrInvalidCredentials = errors.New("Apple did not accept the Apple ID and password") + // ErrSignInNeeded means a request with SavedSignInOnly found no saved + // sign-in it could use. Nothing was asked. + ErrSignInNeeded = errors.New("signing in to Apple is needed") ) // Asker asks the person at the terminal. @@ -99,12 +102,15 @@ type Request struct { // Reset signs in to Apple again instead of reusing a saved sign-in, and // the new one replaces it. Reset bool - Asker Asker + // SavedSignInOnly uses a saved sign-in only: without a valid one the + // adapter returns ErrSignInNeeded instead of asking. + SavedSignInOnly bool + Asker Asker // Log prints a line for the person running the command. Log func(format string, args ...any) } -// Adapter creates APNs keys one way. +// Adapter creates APNs keys, and prepares App IDs, one way. type Adapter interface { // Name is how people pick the adapter, for example "appwrite". Name() string @@ -113,6 +119,15 @@ type Adapter interface { // Apple refuses the sign-in, and a *MaxKeysError when the team has no // free key slot. CreateKey(ctx context.Context, request Request) (Key, error) + // PrepareAppID signs in like CreateKey and makes sure the bundle ID is an + // App ID with Push Notifications: APNs refuses pushes for any other + // bundle ID (TopicDisallowed), and a key alone does not register it. It + // registers the App ID when the team has none and turns on Push + // Notifications when it is off, asking first when appID.Ask is set + // (ConfirmAppIDChange words the questions). With + // request.SavedSignInOnly and no saved sign-in it returns + // ErrSignInNeeded. + PrepareAppID(ctx context.Context, request Request, appID AppIDRequest) (AppIDResult, error) } // AppIDRequest names the App ID to prepare. @@ -134,15 +149,62 @@ type AppIDResult struct { PushTurnedOn bool } -// AppIDPreparer is an Adapter that can also prepare the app's App ID. APNs -// refuses pushes for a bundle ID (TopicDisallowed) unless the team has it -// registered as an App ID with Push Notifications, and a key alone does not -// register it. -type AppIDPreparer interface { - // PrepareAppID signs in like CreateKey, registers the bundle ID as an - // explicit App ID when the team has none, and turns on Push - // Notifications when it is off. - PrepareAppID(ctx context.Context, request Request, appID AppIDRequest) (AppIDResult, error) +// ConfirmAppIDChange asks before an App ID change: registering the bundle ID +// (with Push Notifications) when registered is false, else turning Push +// Notifications on. Every adapter asks with these words. +func ConfirmAppIDChange(asker Asker, bundleID, teamID string, registered bool) (bool, error) { + question := fmt.Sprintf("%s is not registered as an App ID on team %s, so APNs refuses pushes for it. Register it with Push Notifications?", bundleID, teamID) + if registered { + question = fmt.Sprintf("Push Notifications is off for %s, so APNs refuses pushes for it. Turn it on?", bundleID) + } + choice, err := asker.Choose(question, []string{"Yes", "No"}) + + return choice == 0, err +} + +// AppIDName is an App ID's description in the portal, which allows only +// letters, digits and spaces. +func AppIDName(bundleID string) string { + name := strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return r + } + + return ' ' + }, bundleID) + + return strings.Join(strings.Fields(name), " ") +} + +var bundleIDPattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`) + +// PrepareAppID checks the request, runs the adapter, and checks what it +// returned, so every adapter is held to the same contract. +func PrepareAppID(ctx context.Context, adapter Adapter, request Request, appID AppIDRequest) (AppIDResult, error) { + if !bundleIDPattern.MatchString(appID.BundleID) { + return AppIDResult{}, fmt.Errorf("%q is not a bundle ID: a bundle ID has letters, digits, hyphens and dots", appID.BundleID) + } + if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) { + return AppIDResult{}, fmt.Errorf("%q is not an Apple team ID: a team ID is 10 uppercase letters or digits", request.TeamID) + } + if request.Log == nil { + request.Log = func(string, ...any) {} + } + + result, err := adapter.PrepareAppID(ctx, request, appID) + if err != nil { + return result, err + } + switch { + case !teamIDPattern.MatchString(result.TeamID): + return result, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), result.TeamID) + case request.TeamID != "" && result.TeamID != request.TeamID: + return result, fmt.Errorf("the %s APNs setup checked the App ID on team %s, not %s", adapter.Name(), result.TeamID, request.TeamID) + case (result.Created || result.Push) && !result.Registered, result.PushTurnedOn && !result.Push: + return result, fmt.Errorf("the %s APNs setup returned an inconsistent App ID result %+v", adapter.Name(), result) + } + + return result, nil } var ( diff --git a/templates/cli/internal/apns/apns_test.go b/templates/cli/internal/apns/apns_test.go index dc334abeb9..6cf18df473 100644 --- a/templates/cli/internal/apns/apns_test.go +++ b/templates/cli/internal/apns/apns_test.go @@ -96,6 +96,10 @@ func (otherTeam) CreateKey(context.Context, apns.Request) (apns.Key, error) { return apns.Key{KeyID: "NEWKEY1234", TeamID: "OTHER12345", P8: apnstest.P8()}, nil } +func (otherTeam) PrepareAppID(context.Context, apns.Request, apns.AppIDRequest) (apns.AppIDResult, error) { + return apns.AppIDResult{TeamID: "OTHER12345", Registered: true, Push: true}, nil +} + func TestRegistry(t *testing.T) { empty := apns.NewRegistry() if _, err := empty.Get("appwrite"); err == nil || !strings.Contains(err.Error(), "this build has none") { @@ -113,3 +117,85 @@ func TestRegistry(t *testing.T) { t.Errorf("err = %v", err) } } + +func TestPrepareAppIDChecksTheRequest(t *testing.T) { + adapter := &apnstest.Adapter{TeamID: "TEAM123456"} + for _, test := range []struct { + bundleID, teamID, want string + }{ + {"", "", "is not a bundle ID"}, + {"com.example app", "", "is not a bundle ID"}, + {"com.example.app", "team", "is not an Apple team ID"}, + } { + if _, err := apns.PrepareAppID(context.Background(), adapter, apns.Request{TeamID: test.teamID}, apns.AppIDRequest{BundleID: test.bundleID}); err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("%q, %q: err = %v", test.bundleID, test.teamID, err) + } + } + if len(adapter.AppIDRequests) != 0 { + t.Errorf("ran the adapter for a bad request: %+v", adapter.AppIDRequests) + } + + result, err := apns.PrepareAppID(context.Background(), adapter, apns.Request{}, apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) + if err != nil || result != (apns.AppIDResult{TeamID: "TEAM123456", Registered: true, Push: true}) || !adapter.AppIDRequests[0].Ask { + t.Errorf("result = %+v, %v, calls %v", result, err, adapter.AppIDRequests) + } +} + +func TestPrepareAppIDHoldsAdaptersToTheContract(t *testing.T) { + for _, test := range []struct { + name string + adapter apns.Adapter + teamID string + want string + }{ + {"team ID", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "bad", Registered: true}}, "", `invalid team ID "bad"`}, + {"other team", &otherTeam{}, "TEAM123456", "on team OTHER12345, not TEAM123456"}, + {"created but not registered", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "TEAM123456", Created: true}}, "", "inconsistent"}, + {"push turned on but off", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "TEAM123456", Registered: true, PushTurnedOn: true}}, "", "inconsistent"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := apns.PrepareAppID(context.Background(), test.adapter, apns.Request{TeamID: test.teamID}, apns.AppIDRequest{BundleID: "com.example.app"}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("err = %v", err) + } + }) + } + + needed := &apnstest.Adapter{SignInNeeded: true} + if _, err := apns.PrepareAppID(context.Background(), needed, apns.Request{SavedSignInOnly: true}, apns.AppIDRequest{BundleID: "com.example.app"}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Errorf("err = %v", err) + } +} + +func TestAppIDNameKeepsLettersAndDigits(t *testing.T) { + if name := apns.AppIDName("io.appwrite.push-test_2"); name != "io appwrite push test 2" { + t.Errorf("name = %q", name) + } +} + +type choosing struct { + asked []string + choice int +} + +func (c *choosing) Ask(string, bool) (string, error) { return "", errors.New("no questions") } + +func (c *choosing) Choose(question string, options []string) (int, error) { + c.asked = append(c.asked, question+" "+strings.Join(options, "/")) + + return c.choice, nil +} + +func TestConfirmAppIDChange(t *testing.T) { + asker := &choosing{} + if yes, err := apns.ConfirmAppIDChange(asker, "com.example.app", "TEAM123456", false); !yes || err != nil { + t.Errorf("register = %v, %v", yes, err) + } + asker.choice = 1 + if yes, err := apns.ConfirmAppIDChange(asker, "com.example.app", "TEAM123456", true); yes || err != nil { + t.Errorf("turn on = %v, %v", yes, err) + } + if strings.Join(asker.asked, "|") != "com.example.app is not registered as an App ID on team TEAM123456, so APNs refuses pushes for it. Register it with Push Notifications? Yes/No|Push Notifications is off for com.example.app, so APNs refuses pushes for it. Turn it on? Yes/No" { + t.Errorf("asked %v", asker.asked) + } +} diff --git a/templates/cli/internal/apns/apnstest/apnstest.go b/templates/cli/internal/apns/apnstest/apnstest.go index 490b414ed2..ef3ed1af0c 100644 --- a/templates/cli/internal/apns/apnstest/apnstest.go +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -22,6 +22,17 @@ type Adapter struct { TeamID string Err error Requests []apns.Request + + // AppIDResult is what PrepareAppID returns; zero means a registered App + // ID with Push Notifications. Its team defaults like TeamID's. + AppIDResult apns.AppIDResult + AppIDErr error + // SignInNeeded makes a request limited to a saved sign-in get + // apns.ErrSignInNeeded, as when nothing is saved. + SignInNeeded bool + // AppIDRequests and AppIDSignIns record each PrepareAppID call. + AppIDRequests []apns.AppIDRequest + AppIDSignIns []apns.Request } // Name returns AdapterName, or "fake". @@ -47,6 +58,30 @@ func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8()}, nil } +// PrepareAppID records the call and returns AppIDResult or AppIDErr. +func (a *Adapter) PrepareAppID(_ context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + a.AppIDRequests = append(a.AppIDRequests, appID) + a.AppIDSignIns = append(a.AppIDSignIns, request) + if request.SavedSignInOnly && a.SignInNeeded { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + if a.AppIDErr != nil { + return apns.AppIDResult{}, a.AppIDErr + } + result := a.AppIDResult + if result == (apns.AppIDResult{}) { + result = apns.AppIDResult{Registered: true, Push: true} + } + if result.TeamID == "" { + result.TeamID = request.TeamID + } + if result.TeamID == "" { + result.TeamID = a.TeamID + } + + return result, nil +} + // P8 returns a new APNs-shaped private key in PEM form. func P8() string { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -60,25 +95,3 @@ func P8() string { return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) } - -// AppIDAdapter is an Adapter that also prepares App IDs: it records each -// call and returns Result, or AppIDErr when set. -type AppIDAdapter struct { - Adapter - Result apns.AppIDResult - AppIDErr error - AppIDRequests []apns.AppIDRequest - // AppIDSignIns are the sign-in requests each call came with. - AppIDSignIns []apns.Request -} - -// PrepareAppID records the call and returns Result or AppIDErr. -func (a *AppIDAdapter) PrepareAppID(_ context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { - a.AppIDRequests = append(a.AppIDRequests, appID) - a.AppIDSignIns = append(a.AppIDSignIns, request) - if a.AppIDErr != nil { - return apns.AppIDResult{}, a.AppIDErr - } - - return a.Result, nil -} diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go index 4df2739cd9..3b4515c93f 100644 --- a/templates/cli/internal/apns/appwrite/adapter.go +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -95,12 +95,12 @@ func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID } if found == nil { if appID.Ask { - register, err := confirm(request.Asker, fmt.Sprintf("%s is not registered as an App ID on team %s, so APNs refuses pushes for it. Register it with Push Notifications?", appID.BundleID, teamID)) + register, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, teamID, false) if err != nil || !register { return result, err } } - if found, err = portal.createAppID(ctx, teamID, appID.BundleID, appIDName(appID.BundleID)); err != nil { + if found, err = portal.createAppID(ctx, teamID, appID.BundleID, apns.AppIDName(appID.BundleID)); err != nil { return result, err } result.Created = true @@ -109,7 +109,7 @@ func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID if !found.Push { // Registering it was already agreed to as "with Push Notifications". if appID.Ask && !result.Created { - turnOn, err := confirm(request.Asker, fmt.Sprintf("Push Notifications is off for %s, so APNs refuses pushes for it. Turn it on?", appID.BundleID)) + turnOn, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, teamID, true) if err != nil || !turnOn { return result, err } @@ -124,26 +124,6 @@ func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID return result, nil } -// appIDName is the App ID's description in the portal, which allows only -// letters, digits and spaces. -func appIDName(bundleID string) string { - name := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { - return r - } - - return ' ' - }, bundleID) - - return strings.Join(strings.Fields(name), " ") -} - -func confirm(asker apns.Asker, question string) (bool, error) { - choice, err := asker.Choose(question, []string{"Yes", "No"}) - - return choice == 0, err -} - func sessionPath(dir string) string { if dir == "" { return "" @@ -213,6 +193,9 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli withJar.Jar = jar portal.jar = jar } + if request.SavedSignInOnly { + return nil, apns.ErrSignInNeeded + } // An expired session's cookies stay: among them is the one that marks // this machine as trusted, which lets Apple skip two-factor on sign-in. if err := portal.loadWidgetKey(ctx); err != nil { diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index 0768488961..c4439b9f57 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -863,9 +863,18 @@ func TestPrepareAppIDChangesNothingWhenDeclined(t *testing.T) { } } -func TestAppIDNameKeepsLettersAndDigits(t *testing.T) { - if name := appIDName("io.appwrite.push-test_2"); name != "io appwrite push test 2" { - t.Errorf("name = %q", name) +func TestPrepareAppIDWithoutASavedSignInAsksNothing(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{} + client, _ := newTestClient(t, fake, asker, t.TempDir(), nil) + client.request.SavedSignInOnly = true + + _, err := client.adapter.PrepareAppID(context.Background(), client.request, apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) + if !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 || strings.Contains(strings.Join(fake.requests, ","), "/appleauth/") { + t.Errorf("asked %v, requests %v", asker.asked, fake.requests) } } {% endverbatim %} \ No newline at end of file From 45508887ebd710b536d9887998e0a5b8ad0eb2c6 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 18:05:25 +0530 Subject: [PATCH 04/12] feat(cli): prepare App IDs in the expo and fastlane APNs setups The expo helper gains an app-id action on apple-utils' BundleId, and the Fastfile a prepare_app_id lane on spaceship's ConnectAPI BundleId with the portal session. Each checks the App ID first; the Go side asks, then a second run registers it and turns on Push Notifications. A saved-only check restores the setup's own saved session for the Apple ID (apple-utils' cookie file, spaceship's has_valid_session) and never asks, nor offers to install fastlane. --- templates/cli/internal/apns/expo/adapter.go | 135 +++++++++++++--- .../cli/internal/apns/expo/adapter_test.go | 113 +++++++++++++- .../cli/internal/apns/expo/helper/helper.js | 86 ++++++++++- .../cli/internal/apns/fastlane/adapter.go | 144 ++++++++++++++---- .../internal/apns/fastlane/adapter_test.go | 97 ++++++++++++ .../internal/apns/fastlane/helper/Fastfile | 69 ++++++++- 6 files changed, 577 insertions(+), 67 deletions(-) diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index 7d0d3946de..2d2e8b33ae 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -72,12 +72,78 @@ type result struct { Name string `json:"name"` CanRevoke bool `json:"canRevoke"` } `json:"keys"` + // The app-id action's result. + Registered bool `json:"registered"` + Push bool `json:"push"` + Created bool `json:"created"` + PushTurnedOn bool `json:"pushTurnedOn"` } // CreateKey installs the helper if needed and runs it. apple-utils always // creates keys for both APNs environments; for a single-environment request // that key works too. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + if request.Environment != apns.EnvironmentAll && request.Log != nil { + request.Log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + } + arguments := []string{"--name", request.Name} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + contents, err := a.runHelper(ctx, &request, arguments...) + if err != nil { + return apns.Key{}, err + } + + return decodeResult(contents) +} + +// PrepareAppID runs the helper's app-id action: it checks the App ID, and +// after the question, or straight away without one, registers it and turns +// on Push Notifications. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + // apple-utils keeps its saved sign-in per Apple ID, so without one there + // is nothing to restore. + if request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + run := func(mode string) (apns.AppIDResult, error) { + arguments := []string{"--action", "app-id", "--mode", mode, "--bundle-id", appID.BundleID, "--name", apns.AppIDName(appID.BundleID)} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + if request.SavedSignInOnly { + arguments = append(arguments, "--saved-only", "true") + } + contents, err := a.runHelper(ctx, &request, arguments...) + if err != nil { + return apns.AppIDResult{}, err + } + + return decodeAppIDResult(contents) + } + if !appID.Ask { + return run("apply") + } + found, err := run("check") + if err != nil || (found.Registered && found.Push) { + return found, err + } + change, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, found.TeamID, found.Registered) + if err != nil || !change { + return found, err + } + // The check signed in (again, for a reset) and saved the session. + request.Reset = false + request.TeamID = found.TeamID + + return run("apply") +} + +// runHelper installs the helper if needed, runs it with arguments and returns +// the result it wrote. The terminal stays attached, since apple-utils asks +// for what signing in needs. +func (a *Adapter) runHelper(ctx context.Context, request *apns.Request, arguments ...string) ([]byte, error) { log := request.Log if log == nil { log = func(string, ...any) {} @@ -88,7 +154,7 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if request.Reset && strings.TrimSpace(request.AppleID) == "" && request.Asker != nil { answer, err := request.Asker.Ask("Apple ID (email)", false) if err != nil { - return apns.Key{}, err + return nil, err } request.AppleID = strings.TrimSpace(answer) } @@ -96,33 +162,28 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if dir == "" { scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") if err != nil { - return apns.Key{}, err + return nil, err } defer os.RemoveAll(scratch) dir = scratch } tools, err := a.toolchain(ctx, dir, log) if err != nil { - return apns.Key{}, err + return nil, err } if err := a.install(ctx, dir, tools); err != nil { - return apns.Key{}, err - } - if request.Environment != apns.EnvironmentAll { - log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + return nil, err } scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") if err != nil { - return apns.Key{}, err + return nil, err } defer os.RemoveAll(scratch) out := filepath.Join(scratch, "result.json") - arguments := []string{filepath.Join(dir, "helper.js"), "--name", request.Name, "--out", out} - if request.TeamID != "" { - arguments = append(arguments, "--team-id", request.TeamID) - } + arguments = append([]string{filepath.Join(dir, "helper.js")}, arguments...) + arguments = append(arguments, "--out", out) if request.Reset { arguments = append(arguments, "--reset", "true") } @@ -144,13 +205,49 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key contents, err := os.ReadFile(out) if err != nil { if runErr != nil { - return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) + return nil, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) } - return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) + return nil, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) } - return decodeResult(contents) + return contents, nil +} + +func decodeAppIDResult(contents []byte) (apns.AppIDResult, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.AppIDResult{}, fmt.Errorf("the @expo/apple-utils helper returned an unreadable result: %w", err) + } + if err := decoded.err(); err != nil { + return apns.AppIDResult{}, err + } + + return apns.AppIDResult{ + TeamID: decoded.TeamID, + Registered: decoded.Registered, + Push: decoded.Push, + Created: decoded.Created, + PushTurnedOn: decoded.PushTurnedOn, + }, nil +} + +// err is the error the helper reported, other than a full team. +func (r result) err() error { + switch r.Error { + case "": + return nil + case "sign-in-needed": + return apns.ErrSignInNeeded + case "invalid-credentials": + return fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, r.Message) + default: + if r.Message == "" { + return errors.New(r.Error) + } + + return errors.New(r.Message) + } } func decodeResult(contents []byte) (apns.Key, error) { @@ -167,14 +264,8 @@ func decodeResult(contents []byte) (apns.Key, error) { } return apns.Key{}, &apns.MaxKeysError{Keys: keys} - case "invalid-credentials": - return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) default: - if decoded.Message == "" { - decoded.Message = decoded.Error - } - - return apns.Key{}, errors.New(decoded.Message) + return apns.Key{}, decoded.err() } if decoded.KeyID == "" || decoded.P8 == "" { return apns.Key{}, errors.New("the @expo/apple-utils helper returned no key") diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go index edf7ff9cbc..96f373e50c 100644 --- a/templates/cli/internal/apns/expo/adapter_test.go +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -109,8 +109,8 @@ func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { t.Fatalf("calls:\n%s", calls) } for _, line := range lines[1:] { - if !strings.Contains(line, "helper.js --name Appwrite Push --out ") || - !strings.HasSuffix(line, "--team-id ABCDE12345 EXPO_NO_KEYCHAIN=1 EXPO_APPLE_ID=dev@example.com") { + if !strings.Contains(line, "helper.js --name Appwrite Push --team-id ABCDE12345 --out ") || + !strings.HasSuffix(line, " EXPO_NO_KEYCHAIN=1 EXPO_APPLE_ID=dev@example.com") { t.Errorf("helper call = %s", line) } } @@ -223,3 +223,112 @@ func TestResetAsksForTheAppleIDOnce(t *testing.T) { t.Errorf("asked %v, %v", known.asked, err) } } + +// choosing answers Choose with choice and records the question. +type choosing struct { + asked []string + choice int +} + +func (c *choosing) Ask(question string, _ bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosing) Choose(question string, _ []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +func helperCalls(t *testing.T, log string) []string { + t.Helper() + calls, _ := os.ReadFile(log) + var helper []string + for _, line := range strings.Split(strings.TrimSpace(string(calls)), "\n") { + if strings.Contains(line, "helper.js") { + helper = append(helper, line) + } + } + + return helper +} + +func TestPrepareAppIDWithoutAskingAppliesInOneRun(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":true,"push":true,"created":true,"pushTurnedOn":true}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{TeamID: "ABCDE12345", AppleID: "dev@example.com", SessionDir: t.TempDir()} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app"}) + if err != nil || result != (apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}) { + t.Fatalf("result = %+v, %v", result, err) + } + calls := helperCalls(t, log) + if len(calls) != 1 || !strings.Contains(calls[0], "helper.js --action app-id --mode apply --bundle-id io.example.app --name io example app --team-id ABCDE12345 --out ") { + t.Errorf("helper calls = %v", calls) + } +} + +func TestPrepareAppIDOnlyChecksAReadyAppID(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":true,"push":true}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosing{} + request := apns.Request{AppleID: "dev@example.com", SessionDir: t.TempDir(), Asker: asker} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || !result.Registered || !result.Push || result.Created { + t.Fatalf("result = %+v, %v", result, err) + } + if calls := helperCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "--mode check") || len(asker.asked) != 0 { + t.Errorf("helper calls = %v, asked %v", calls, asker.asked) + } +} + +func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":false,"push":false}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosing{} + request := apns.Request{AppleID: "dev@example.com", SessionDir: t.TempDir(), Asker: asker, Reset: true} + + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); err != nil { + t.Fatal(err) + } + calls := helperCalls(t, log) + if len(calls) != 2 || !strings.Contains(calls[0], "--mode check") || !strings.Contains(calls[0], "--reset true") || + !strings.Contains(calls[1], "--mode apply") || !strings.Contains(calls[1], "--team-id ABCDE12345") || strings.Contains(calls[1], "--reset") { + t.Errorf("helper calls = %v", calls) + } + if len(asker.asked) != 1 || !strings.HasPrefix(asker.asked[0], "io.example.app is not registered as an App ID on team ABCDE12345") { + t.Errorf("asked %v", asker.asked) + } + + // Declining runs nothing more. + declined := &choosing{choice: 1} + request.Asker = declined + before := len(helperCalls(t, log)) + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || result.Registered || len(helperCalls(t, log)) != before+1 { + t.Errorf("result = %+v, %v, calls %v", result, err, helperCalls(t, log)) + } +} + +func TestPrepareAppIDWithTheSavedSignInOnly(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"error":"sign-in-needed"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{SessionDir: t.TempDir(), SavedSignInOnly: true} + + // apple-utils keeps sessions per Apple ID: without one there is none. + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := helperCalls(t, log); len(calls) != 0 { + t.Fatalf("ran the helper without an Apple ID: %v", calls) + } + + request.AppleID = "dev@example.com" + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := helperCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "--saved-only true") { + t.Errorf("helper calls = %v", calls) + } +} diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js index 6b992760b2..4fcdd51a82 100644 --- a/templates/cli/internal/apns/expo/helper/helper.js +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -1,14 +1,22 @@ 'use strict'; -// Creates a team-scoped APNs key through the Apple Developer portal with -// @expo/apple-utils, the library eas-cli uses for the same job. +// Creates a team-scoped APNs key, or prepares the app's App ID, through the +// Apple Developer portal with @expo/apple-utils, the library eas-cli uses for +// the same jobs. // // apple-utils asks for the Apple ID, the password and the two-factor code // itself, so the CLI runs this script with the terminal attached. Its logging // goes to stdout, so the result is written to the file named by --out instead. const fs = require('fs'); -const { Auth, Keys, InvalidUserCredentialsError } = require('@expo/apple-utils'); +const { + Auth, + BundleId, + CapabilityType, + CapabilityTypeOption, + InvalidUserCredentialsError, + Keys, +} = require('@expo/apple-utils'); function parseArguments(argv) { const values = {}; @@ -16,7 +24,9 @@ function parseArguments(argv) { values[argv[index].replace(/^--/, '')] = argv[index + 1]; } if (!values.out || !values.name) { - throw new Error('usage: helper.js [--team-id ] [--reset true] --name --out '); + throw new Error( + 'usage: helper.js [--action app-id --bundle-id --mode check|apply [--saved-only true]] [--team-id ] [--reset true] --name --out ' + ); } return values; @@ -35,19 +45,36 @@ function isMaxKeysError(error) { ); } -async function createKey({ 'team-id': teamId, name, reset }) { +// signIn returns the request context, or null when --saved-only finds no +// saved sign-in for EXPO_APPLE_ID. +async function signIn({ 'team-id': teamId, reset, 'saved-only': savedOnly }) { // --reset deletes apple-utils' saved session for this Apple ID first, so the // sign-in starts over, two-factor included. if (reset === 'true') { await Auth.logoutAsync({}); } - // Without a team ID, apple-utils asks which team to use when there are several. - const authState = await Auth.loginAsync(teamId ? { teamId } : {}, { autoResolveProvider: true }); + let authState; + if (savedOnly === 'true') { + authState = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { username: process.env.EXPO_APPLE_ID, teamId }, + { autoResolveProvider: true } + ); + if (!authState) { + return null; + } + } else { + // Without a team ID, apple-utils asks which team to use when there are several. + authState = await Auth.loginAsync(teamId ? { teamId } : {}, { autoResolveProvider: true }); + } const context = authState.context; if (teamId && context.teamId !== teamId) { throw new Error(`Signed in to Apple team ${context.teamId}, not ${teamId}`); } + return context; +} + +async function createKey(context, { name }) { let key; try { key = await Keys.createKeyAsync(context, { name, isApns: true }); @@ -67,11 +94,54 @@ async function createKey({ 'team-id': teamId, name, reset }) { return { keyId: key.id, teamId: context.teamId, p8 }; } +// prepareAppId reports whether --bundle-id is an App ID with Push +// Notifications, and with --mode apply registers it and turns push on. +async function prepareAppId(context, { 'bundle-id': identifier, name, mode }) { + let bundleId = await BundleId.findAsync(context, { identifier }); + const result = { + teamId: context.teamId, + registered: Boolean(bundleId), + push: false, + created: false, + pushTurnedOn: false, + }; + if (bundleId) { + result.push = Boolean(await bundleId.hasCapabilityAsync(CapabilityType.PUSH_NOTIFICATIONS)); + } + if (mode !== 'apply') { + return result; + } + if (!bundleId) { + bundleId = await BundleId.createAsync(context, { name, identifier }); + result.registered = true; + result.created = true; + } + if (!result.push) { + await bundleId.updateBundleIdCapabilityAsync({ + capabilityType: CapabilityType.PUSH_NOTIFICATIONS, + option: CapabilityTypeOption.ON, + }); + result.push = true; + result.pushTurnedOn = true; + } + + return result; +} + +async function run(options) { + const context = await signIn(options); + if (!context) { + return { error: 'sign-in-needed' }; + } + + return options.action === 'app-id' ? prepareAppId(context, options) : createKey(context, options); +} + async function main() { const options = parseArguments(process.argv.slice(2)); let result; try { - result = await createKey(options); + result = await run(options); } catch (error) { const kind = error instanceof InvalidUserCredentialsError ? 'invalid-credentials' : 'failed'; result = { error: kind, message: error?.message ?? String(error) }; diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index 3e9e1f2b25..ef5f8066d4 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -34,7 +34,7 @@ var fastfile []byte // Temporarily Unavailable" when signing in. var minimumVersion = [3]int{2, 225, 0} -// Adapter creates APNs keys with an installed fastlane. +// Adapter creates APNs keys, and prepares App IDs, with an installed fastlane. type Adapter struct { // Fastlane is the command to run. Empty means the project's Bundler // fastlane when there is one, otherwise "fastlane" from PATH. @@ -75,26 +75,84 @@ type result struct { Name string `json:"name"` CanRevoke bool `json:"canRevoke"` } `json:"keys"` + // The prepare_app_id lane's result. + Registered bool `json:"registered"` + Push bool `json:"push"` + Created bool `json:"created"` + PushTurnedOn bool `json:"pushTurnedOn"` } -// CreateKey runs the embedded lane. The Apple ID and password are asked here -// when the request has none, and handed to fastlane as FASTLANE_USER and -// FASTLANE_PASSWORD, so fastlane never offers to keep them in the Keychain. +// CreateKey runs the embedded create_apns_key lane. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { - runner, err := a.runner() + arguments := []string{"name:" + request.Name, "environment:" + string(request.Environment)} + contents, err := a.runLane(ctx, request, "create_apns_key", arguments...) if err != nil { return apns.Key{}, err } + + return decodeResult(contents) +} + +// PrepareAppID runs the embedded prepare_app_id lane: it checks the App ID, +// and after the question, or straight away without one, registers it and +// turns on Push Notifications. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + // spaceship keeps its saved sign-in per Apple ID, so without one there is + // nothing to restore. + if request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + run := func(mode string) (apns.AppIDResult, error) { + arguments := []string{"mode:" + mode, "bundle_id:" + appID.BundleID, "name:" + apns.AppIDName(appID.BundleID)} + if request.SavedSignInOnly { + arguments = append(arguments, "saved_only:true") + } + contents, err := a.runLane(ctx, request, "prepare_app_id", arguments...) + if err != nil { + return apns.AppIDResult{}, err + } + + return decodeAppIDResult(contents) + } + if !appID.Ask { + return run("apply") + } + found, err := run("check") + if err != nil || (found.Registered && found.Push) { + return found, err + } + change, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, found.TeamID, found.Registered) + if err != nil || !change { + return found, err + } + // The check signed in (again, for a reset) and saved the session. + request.Reset = false + request.TeamID = found.TeamID + + return run("apply") +} + +// runLane runs a lane of the embedded Fastfile and returns the result it +// wrote. The Apple ID and password are asked here when the request has none, +// and handed to fastlane as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane +// never offers to keep them in the Keychain. With SavedSignInOnly nothing is +// asked: the lane uses spaceship's saved session for the Apple ID or reports +// that there is none. +func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string, arguments ...string) ([]byte, error) { + runner, err := a.runner() + if err != nil { + return nil, err + } err = runner.check(ctx) - // Installing is only offered for fastlane from PATH: a configured command - // is never replaced. - if errors.Is(err, errNotInstalled) && a.Fastlane == "" { + // Installing is only offered for fastlane from PATH, never replacing a + // configured command, and never during a check that must not ask. + if errors.Is(err, errNotInstalled) && a.Fastlane == "" && !request.SavedSignInOnly { if err = a.offerInstall(ctx, request); err == nil { err = runner.check(ctx) } } if err != nil { - return apns.Key{}, err + return nil, err } if runner.gemfile != "" && request.Log != nil { request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) @@ -104,15 +162,15 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if appleID == "" { answer, err := request.Asker.Ask("Apple ID (email)", false) if err != nil { - return apns.Key{}, err + return nil, err } appleID = strings.TrimSpace(answer) } password := request.Password - if password == "" { + if password == "" && !request.SavedSignInOnly { answer, err := request.Asker.Ask("Password for "+appleID, true) if err != nil { - return apns.Key{}, err + return nil, err } password = answer } @@ -120,18 +178,18 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key // fastlane runs the Fastfile in ./fastlane of its working directory. scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") if err != nil { - return apns.Key{}, err + return nil, err } defer os.RemoveAll(scratch) if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { - return apns.Key{}, err + return nil, err } if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { - return apns.Key{}, err + return nil, err } out := filepath.Join(scratch, "result.json") - arguments := []string{"create_apns_key", "out:" + out, "name:" + request.Name, "environment:" + string(request.Environment)} + arguments = append([]string{lane, "out:" + out}, arguments...) if request.TeamID != "" { arguments = append(arguments, "team_id:"+request.TeamID) } @@ -143,23 +201,61 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key command.Stdin, command.Stdout, command.Stderr = a.terminal() command.Env = append(command.Env, "FASTLANE_USER="+appleID, - "FASTLANE_PASSWORD="+password, "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1", "FASTLANE_HIDE_CHANGELOG=1", ) + if password != "" { + command.Env = append(command.Env, "FASTLANE_PASSWORD="+password) + } runErr := command.Run() contents, err := os.ReadFile(out) if err != nil { if runErr != nil { - return apns.Key{}, fmt.Errorf("the fastlane lane failed: %w", runErr) + return nil, fmt.Errorf("the fastlane lane failed: %w", runErr) } - return apns.Key{}, fmt.Errorf("the fastlane lane returned no result: %w", err) + return nil, fmt.Errorf("the fastlane lane returned no result: %w", err) } - return decodeResult(contents) + return contents, nil +} + +func decodeAppIDResult(contents []byte) (apns.AppIDResult, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.AppIDResult{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + if err := decoded.err(); err != nil { + return apns.AppIDResult{}, err + } + + return apns.AppIDResult{ + TeamID: decoded.TeamID, + Registered: decoded.Registered, + Push: decoded.Push, + Created: decoded.Created, + PushTurnedOn: decoded.PushTurnedOn, + }, nil +} + +// err is the error the lane reported, other than a full team. +func (r result) err() error { + switch r.Error { + case "": + return nil + case "sign-in-needed": + return apns.ErrSignInNeeded + case "invalid-credentials": + return fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, r.Message) + default: + if r.Message == "" { + return errors.New(r.Error) + } + + return errors.New(r.Message) + } } func decodeResult(contents []byte) (apns.Key, error) { @@ -176,14 +272,8 @@ func decodeResult(contents []byte) (apns.Key, error) { } return apns.Key{}, &apns.MaxKeysError{Keys: keys} - case "invalid-credentials": - return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) default: - if decoded.Message == "" { - decoded.Message = decoded.Error - } - - return apns.Key{}, errors.New(decoded.Message) + return apns.Key{}, decoded.err() } if decoded.KeyID == "" || decoded.P8 == "" { return apns.Key{}, errors.New("the fastlane lane returned no key") diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index d781813c66..2d8934fff4 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -442,3 +442,100 @@ func TestCreateKeyPassesResetToTheLane(t *testing.T) { t.Errorf("lane call = %s", calls) } } + +func laneCalls(t *testing.T, log string) []string { + t.Helper() + calls, _ := os.ReadFile(log) + text := strings.TrimSpace(string(calls)) + if text == "" { + return nil + } + + return strings.Split(text, "\n") +} + +func TestPrepareAppIDWithoutAskingAppliesInOneRun(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":true,"created":true,"pushTurnedOn":true}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{TeamID: "ABCDE12345", AppleID: "dev@example.com", Password: "secret", Asker: &choosingAsker{}} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app"}) + if err != nil || result != (apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}) { + t.Fatalf("result = %+v, %v", result, err) + } + calls := laneCalls(t, log) + if len(calls) != 1 || !strings.HasPrefix(calls[0], "fastlane prepare_app_id out:") || + !strings.HasSuffix(calls[0], "mode:apply bundle_id:io.example.app name:io example app team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("lane calls = %v", calls) + } +} + +func TestPrepareAppIDOnlyChecksAReadyAppID(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":true}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", Password: "secret", Asker: asker} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || !result.Registered || !result.Push { + t.Fatalf("result = %+v, %v", result, err) + } + if calls := laneCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "mode:check") || len(asker.asked) != 0 { + t.Errorf("lane calls = %v, asked %v", calls, asker.asked) + } +} + +func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":false}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", Password: "secret", Asker: asker, Reset: true} + + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); err != nil { + t.Fatal(err) + } + calls := laneCalls(t, log) + if len(calls) != 2 || !strings.Contains(calls[0], "mode:check") || !strings.Contains(calls[0], "reset:true") || + !strings.Contains(calls[1], "mode:apply") || !strings.Contains(calls[1], "team_id:ABCDE12345") || strings.Contains(calls[1], "reset:true") { + t.Errorf("lane calls = %v", calls) + } + if strings.Join(asker.asked, "|") != "Push Notifications is off for io.example.app, so APNs refuses pushes for it. Turn it on?" { + t.Errorf("asked %v", asker.asked) + } +} + +func TestPrepareAppIDWithTheSavedSignInOnly(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"error":"sign-in-needed"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{SavedSignInOnly: true, Asker: &choosingAsker{}} + + // spaceship keeps sessions per Apple ID: without one there is none. + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := laneCalls(t, log); len(calls) != 0 { + t.Fatalf("ran the lane without an Apple ID: %v", calls) + } + + // With one, the lane checks the saved session; no password is asked. + request.AppleID = "dev@example.com" + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := laneCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "saved_only:true") || !strings.HasSuffix(calls[0], "password=unset") { + t.Errorf("lane calls = %v", calls) + } +} + +func TestASavedSignInCheckNeverOffersToInstallFastlane(t *testing.T) { + bin := withoutFastlane(t) + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + fakeBrew(t, bin, fastlane) + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", SavedSignInOnly: true, Asker: asker} + + _, err := (&Adapter{}).PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if !errors.Is(err, apns.ErrUnavailable) || len(asker.asked) != 0 { + t.Errorf("err = %v, asked %v", err, asker.asked) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile index c7e60b70c2..6deaaf0d83 100644 --- a/templates/cli/internal/apns/fastlane/helper/Fastfile +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -1,6 +1,7 @@ -# Creates a team-scoped APNs key for `appwrite init apns --create-key -# --provider fastlane` with spaceship, fastlane's Apple Developer portal -# client, so any fastlane installation (gem, Homebrew or bundler) can run it. +# Creates a team-scoped APNs key, or prepares the app's App ID, for +# `appwrite init apns --provider fastlane` with spaceship, fastlane's Apple +# Developer portal client, so any fastlane installation (gem, Homebrew or +# bundler) can run it. # # spaceship asks for the two-factor code, and for a team when the account has # several, on the terminal. The Apple ID and password come from FASTLANE_USER @@ -9,8 +10,16 @@ require 'json' lane :create_apns_key do |options| + write_result(options) { create_apns_key_with_spaceship(options) } +end + +lane :prepare_app_id do |options| + write_result(options) { prepare_app_id_with_spaceship(options) } +end + +def write_result(options) result = begin - create_apns_key_with_spaceship(options) + yield rescue StandardError => error kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' { error: kind, message: error.message } @@ -18,23 +27,38 @@ lane :create_apns_key do |options| File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } end -def create_apns_key_with_spaceship(options) +# sign_in returns the portal client for the team, or nil when saved_only:true +# finds no saved sign-in for FASTLANE_USER. +def sign_in(options) require 'spaceship' + user = ENV.fetch('FASTLANE_USER', nil) # reset:true deletes spaceship's saved session for this Apple ID first, so # the sign-in starts over, two-factor included. if options[:reset].to_s == 'true' - cookie = Spaceship::PortalClient.new.tap { |client| client.user = ENV.fetch('FASTLANE_USER', nil) }.persistent_cookie_path + cookie = Spaceship::PortalClient.new.tap { |client| client.user = user }.persistent_cookie_path File.delete(cookie) if cookie && File.exist?(cookie) end - Spaceship::Portal.login(ENV.fetch('FASTLANE_USER', nil), ENV.fetch('FASTLANE_PASSWORD', nil)) + if options[:saved_only].to_s == 'true' + client = Spaceship::PortalClient.new + client.user = user + return nil unless client.has_valid_session + + Spaceship::Portal.client = client + else + Spaceship::Portal.login(user, ENV.fetch('FASTLANE_PASSWORD', nil)) + end team_id = options[:team_id].to_s if team_id.empty? Spaceship::Portal.select_team else Spaceship::Portal.select_team(team_id: team_id) end - client = Spaceship::Portal.client + Spaceship::Portal.client +end + +def create_apns_key_with_spaceship(options) + client = sign_in(options) # Key.create always asks for every environment, so the client is called # directly with the one requested. @@ -59,3 +83,32 @@ def create_apns_key_with_spaceship(options) key_id = created['keyId'] { keyId: key_id, teamId: client.team_id, p8: client.download_key(id: key_id) } end + +# Reports whether bundle_id: is an App ID with Push Notifications, and with +# mode:apply registers it and turns push on. +def prepare_app_id_with_spaceship(options) + client = sign_in(options) + return { error: 'sign-in-needed' } unless client + + push_type = Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS + Spaceship::ConnectAPI.client = Spaceship::ConnectAPI::Client.new(portal_client: client) + identifier = options[:bundle_id].to_s + bundle = Spaceship::ConnectAPI::BundleId.find(identifier, includes: 'bundleIdCapabilities') + push = !bundle.nil? && (bundle.bundle_id_capabilities || []).any? { |capability| capability.is_type?(push_type) } + result = { teamId: client.team_id, registered: !bundle.nil?, push: push, created: false, pushTurnedOn: false } + return result unless options[:mode].to_s == 'apply' + + if bundle.nil? + bundle = Spaceship::ConnectAPI::BundleId.create( + name: options[:name].to_s, platform: 'IOS', identifier: identifier, seed_id: client.team_id + ) + result[:registered] = true + result[:created] = true + end + unless result[:push] + bundle.update_capability(push_type, enabled: true) + result[:push] = true + result[:pushTurnedOn] = true + end + result +end From ced40be4554a868d0cf8c0af9ab1c22520147357 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 18:05:25 +0530 Subject: [PATCH 05/12] feat(cli): check the App ID on every init apns run init apns checks the App ID whenever it runs, with any setup: right after a new key with that sign-in, else with a saved sign-in, else after asking whether to sign in (skipped without a terminal). Changes are asked about first. --register-app-id signs in when needed, changes without asking and fails the command when it cannot. --- templates/cli/internal/cmd/initpush.go | 63 ++++++---- templates/cli/internal/cmd/initpush_test.go | 127 +++++++++++++------- 2 files changed, 128 insertions(+), 62 deletions(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index a3959c1cb7..dbaffe58c0 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -416,11 +416,8 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { output.Success(s.out, "APNs%s is already set up for %s.", environmentLabel(environment), bundleID) output.Hint(s.out, "Pass --force or --key-path to replace the key.") s.configureXcode(detected, bundleID) - if options.registerAppID { - return s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options) - } - return nil + return s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options, false) } teams := detected.teams(bundleID) @@ -516,13 +513,9 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { s.configureXcode(detected, bundleID) output.Success(s.out, "APNs%s is set up for %s.", environmentLabel(environment), bundleID) - // Signed in to Apple already when a key was just created, so the App ID - // is checked too; --register-app-id asks for it on its own. - if createdKeyID != "" || options.registerAppID { - return s.prepareAppID(teamID, bundleID, options) - } - return nil + // A key created a moment ago left a fresh sign-in to check the App ID with. + return s.prepareAppID(teamID, bundleID, options, createdKeyID != "") } // apnsKey finds or creates the APNs key and returns its path. With @@ -684,8 +677,14 @@ func (s *pushSetup) appleRequest(adapter apns.Adapter, teamID string, reset bool if s.apnsSessions != "" { request.SessionDir = filepath.Join(s.apnsSessions, adapter.Name()) } + + return request +} + +// logAppleSignIn says how the coming sign-in to Apple will happen. +func (s *pushSetup) logAppleSignIn(adapter apns.Adapter, request apns.Request) { output.Log(s.out, "Signing in to the Apple Developer portal (%s).", adapter.Name()) - if reset { + if request.Reset { output.Log(s.out, "Signing in again: the saved Apple sign-in is not used (--reset).") } if request.AppleID != "" { @@ -694,16 +693,18 @@ func (s *pushSetup) appleRequest(adapter apns.Adapter, teamID string, reset bool if request.Password != "" { output.Log(s.out, "Using the Apple ID password from %s.", applePasswordVariable) } - - return request } // prepareAppID makes sure the bundle ID is an App ID with Push Notifications // on the team: APNs refuses pushes for any other bundle ID (TopicDisallowed), // and Xcode registers it only for a device build with the capability added. -// After a new key it asks before each change; --register-app-id makes them -// directly and fails the command when it cannot. -func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions) error { +// +// Every init apns runs it. It uses the sign-in a new key just left, or a +// saved one, and asks before each change; without either it asks whether to +// sign in, and skips when there is no terminal. --register-app-id signs in +// when needed, makes the changes without asking and fails the command when +// it cannot. +func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions, signedIn bool) error { explicit := options.registerAppID fail := func(format string, args ...any) error { message := fmt.Sprintf(format, args...) @@ -719,15 +720,34 @@ func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions) e if err != nil { return fail("Could not check the App ID for %s: %s", bundleID, err) } - preparer, ok := adapter.(apns.AppIDPreparer) - if !ok { - return fail("The %s setup cannot check App IDs, so %s was not checked", adapter.Name(), bundleID) - } output.Log(s.out, "Checking the App ID for %s ...", bundleID) // A key created a moment ago left a fresh sign-in: --reset is not repeated. request := s.appleRequest(adapter, teamID, options.reset && !options.createKey) - result, err := preparer.PrepareAppID(s.context(), request, apns.AppIDRequest{BundleID: bundleID, Ask: !explicit}) + request.SavedSignInOnly = !signedIn && !explicit + if explicit && !signedIn { + s.logAppleSignIn(adapter, request) + } + appID := apns.AppIDRequest{BundleID: bundleID, Ask: !explicit} + result, err := apns.PrepareAppID(s.context(), adapter, request, appID) + if errors.Is(err, apns.ErrSignInNeeded) { + answer, askErr := s.prompter.Choice(prompt.Choice{ + Message: "Sign in with your Apple ID to check the App ID for " + bundleID + "?", + Description: "APNs refuses pushes for a bundle ID that is not registered as an App ID with Push Notifications.", + Options: []prompt.Option{ + {Label: "Sign in and check", Value: "check"}, + {Label: "Skip", Value: "skip"}, + }, + }) + if askErr != nil || answer != "check" { + output.Hint(s.out, "Skipped the App ID check. Run `appwrite init apns --register-app-id`, or check %s at %s.", bundleID, appIDsPage) + + return nil + } + request.SavedSignInOnly = false + s.logAppleSignIn(adapter, request) + result, err = apns.PrepareAppID(s.context(), adapter, request, appID) + } var unanswered *prompt.NonInteractiveError if errors.As(err, &unanswered) { return fail("Could not check the App ID for %s: %q needs an interactive terminal. Pass --register-app-id to register it without asking", bundleID, unanswered.Message) @@ -778,6 +798,7 @@ func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environmen return "", "", "", err } request := s.appleRequest(adapter, teamID, options.reset) + s.logAppleSignIn(adapter, request) request.Name = "Appwrite Push " + time.Now().Format("20060102150405") request.Environment = environment key, err := apns.CreateKey(s.context(), adapter, request) diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index aa41174fc8..5c93fa21b9 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -1649,6 +1649,10 @@ type askingAdapter struct{} func (askingAdapter) Name() string { return defaultApnsSetup } +func (askingAdapter) PrepareAppID(_ context.Context, request apns.Request, _ apns.AppIDRequest) (apns.AppIDResult, error) { + return apns.AppIDResult{TeamID: request.TeamID, Registered: true, Push: true}, nil +} + func (askingAdapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { if _, err := request.Asker.Ask("Enter the 6-digit code sent to +1 •••12", false); err != nil { return apns.Key{}, err @@ -1720,27 +1724,18 @@ func TestInitApnsResetSignsInAgainAndCreatesAKey(t *testing.T) { } } -// useAppIDAdapter makes adapter, which can also prepare App IDs, the only APNs -// key setup. -func useAppIDAdapter(setup *pushSetup, adapter *apnstest.AppIDAdapter) *apnstest.AppIDAdapter { - adapter.AdapterName = defaultApnsSetup - if adapter.TeamID == "" { - adapter.TeamID = "APPLE12345" - } - setup.apnsAdapters = apns.NewRegistry(adapter) - - return adapter -} +const appIDSignInQuestion = "Sign in with your Apple ID to check the App ID for com.example.app?" func TestInitApnsChecksTheAppIDAfterCreatingAKey(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) defer server.Close() - setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) - adapter := useAppIDAdapter(setup, &apnstest.AppIDAdapter{ - Adapter: apnstest.Adapter{KeyID: "NEWKEY1234"}, - Result: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + KeyID: "NEWKEY1234", + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, }) options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true} @@ -1750,64 +1745,114 @@ func TestInitApnsChecksTheAppIDAfterCreatingAKey(t *testing.T) { if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0] != (apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) { t.Fatalf("App ID calls = %v", adapter.AppIDRequests) } - // The key's sign-in just happened: --reset does not sign in a second time. - if signIn := adapter.AppIDSignIns[0]; signIn.TeamID != "ABCDE12345" || signIn.Reset { + // The key's sign-in is reused: no question, and --reset does not sign in + // a second time. + if signIn := adapter.AppIDSignIns[0]; signIn.TeamID != "ABCDE12345" || signIn.Reset || signIn.SavedSignInOnly { t.Errorf("App ID sign-in = %+v", signIn) } + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } if !strings.Contains(out.String(), "Registered com.example.app as an App ID with Push Notifications on team ABCDE12345.") { t.Errorf("output:\n%s", out.String()) } } -func TestInitApnsRegistersTheAppIDOfASetUpProject(t *testing.T) { +func TestInitApnsChecksTheAppIDWithTheSavedSignIn(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) defer server.Close() - setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) - adapter := useAppIDAdapter(setup, &apnstest.AppIDAdapter{ - Result: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, PushTurnedOn: true}, + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true}, }) + keyPath := testApnsKey(t, t.TempDir()) if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { t.Fatal(err) } - if len(adapter.AppIDRequests) != 0 { - t.Fatalf("checked the App ID without a sign-in to reuse: %v", adapter.AppIDRequests) + if len(adapter.AppIDRequests) != 1 || !adapter.AppIDRequests[0].Ask || !adapter.AppIDSignIns[0].SavedSignInOnly { + t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } + if len(scripted.Asked) != 0 || !strings.Contains(out.String(), "com.example.app is registered as an App ID with Push Notifications.") { + t.Errorf("asked %v, output:\n%s", scripted.Asked, out.String()) } - // Already set up: --register-app-id runs on its own, without asking, - // on the team of the existing providers. - if err := setup.apns(apnsOptions{bundleID: "com.example.app", registerAppID: true}, appleApp{}); err != nil { + // An already set-up project is checked on every run. + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { t.Fatal(err) } - if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0].Ask || adapter.AppIDSignIns[0].TeamID != "ABCDE12345" { - t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + if len(adapter.AppIDRequests) != 2 || adapter.AppIDSignIns[1].TeamID != "ABCDE12345" { + t.Errorf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) } - if !strings.Contains(out.String(), "Turned on Push Notifications for the App ID com.example.app.") { +} + +func TestInitApnsAsksBeforeSigningInToCheckTheAppID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + SignInNeeded: true, + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, PushTurnedOn: true}, + }) + keyPath := testApnsKey(t, t.TempDir()) + + // No saved sign-in and no answer: skipped, and the setup still succeeds. + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || len(adapter.AppIDRequests) != 1 || strings.Join(scripted.Asked, "|") != appIDSignInQuestion { + t.Fatalf("providers = %v, App ID calls = %v, asked %v", messaging.providers, adapter.AppIDRequests, scripted.Asked) + } + if !strings.Contains(out.String(), "Skipped the App ID check. Run `appwrite init apns --register-app-id`") { t.Errorf("output:\n%s", out.String()) } - // Asked for explicitly, a failure fails the command. - adapter.AppIDErr = errors.New("could not reach Apple") - err := setup.apns(apnsOptions{bundleID: "com.example.app", registerAppID: true}, appleApp{}) - if err == nil || !strings.Contains(err.Error(), "Could not check the App ID for com.example.app: could not reach Apple") { - t.Errorf("err = %v", err) + // Agreeing signs in and checks. + scripted.Choices = map[string]string{appIDSignInQuestion: "check"} + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 3 || adapter.AppIDSignIns[2].SavedSignInOnly || !adapter.AppIDRequests[2].Ask { + t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } + if !strings.Contains(out.String(), "Turned on Push Notifications for the App ID com.example.app.") { + t.Errorf("output:\n%s", out.String()) } } -func TestInitApnsWarnsWhenTheSetupCannotCheckAppIDs(t *testing.T) { +func TestInitApnsRegisterAppIDSignsInAndDoesNotAsk(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) defer server.Close() - setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) - useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) - - if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}); err != nil { + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + SignInNeeded: true, + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, + }) + keyPath := testApnsKey(t, t.TempDir()) + options := apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345", registerAppID: true} + if err := setup.apns(options, appleApp{}); err != nil { t.Fatal(err) } - if len(messaging.providers) != 2 || !strings.Contains(out.String(), "The appwrite setup cannot check App IDs, so com.example.app was not checked.") { - t.Errorf("providers = %v, output:\n%s", messaging.providers, out.String()) + if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0].Ask || adapter.AppIDSignIns[0].SavedSignInOnly || len(scripted.Asked) != 0 { + t.Fatalf("App ID calls = %v, sign-ins = %v, asked %v", adapter.AppIDRequests, adapter.AppIDSignIns, scripted.Asked) + } + if !strings.Contains(out.String(), "Registered com.example.app as an App ID with Push Notifications on team ABCDE12345.") { + t.Errorf("output:\n%s", out.String()) + } + + // Asked for explicitly, a failure fails the command. + adapter.AppIDErr = errors.New("could not reach Apple") + err := setup.apns(apnsOptions{bundleID: "com.example.app", registerAppID: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "Could not check the App ID for com.example.app: could not reach Apple") { + t.Errorf("err = %v", err) } } From 20256155c561c6c1657abb054d782ffa738aa9ea Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 18:15:23 +0530 Subject: [PATCH 06/12] fix(cli): read every page of the App ID lookup The portal's identifier filter also returns longer identifiers that start with the bundle ID, and the lookup read only the first page of 200. With more of those, the exact App ID could be on a later page, be taken as missing, and get a duplicate create that Apple refuses. The lookup now follows links.next until it finds the exact identifier or the pages run out. --- .../internal/apns/appwrite/adapter_test.go | 44 +++++++++++++++-- templates/cli/internal/apns/appwrite/appid.go | 48 ++++++++++++++++--- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index c4439b9f57..f53680a0da 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -15,6 +15,8 @@ import ( "net/url" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "testing" @@ -60,6 +62,8 @@ type fakeApple struct { created map[string]map[string]any // appIDs are the team's App IDs by identifier. appIDs map[string]*fakeAppID + // appIDPages counts the App ID list pages served. + appIDPages int } type fakeAppID struct { @@ -337,11 +341,21 @@ func (f *fakeApple) connectAPI(w http.ResponseWriter, r *http.Request, path stri } data := []map[string]any{} included := []map[string]any{} - for identifier, appID := range f.appIDs { - // Like Apple, the filter matches by prefix. - if !strings.HasPrefix(identifier, query.Get("filter[identifier]")) { - continue + // Like Apple, the filter matches by prefix, and the list comes in + // pages of limit, in ID order, with a cursor to the next. + var matching []string + for identifier := range f.appIDs { + if strings.HasPrefix(identifier, query.Get("filter[identifier]")) { + matching = append(matching, identifier) } + } + sort.Slice(matching, func(i, j int) bool { return f.appIDs[matching[i]].id < f.appIDs[matching[j]].id }) + limit, _ := strconv.Atoi(query.Get("limit")) + offset, _ := strconv.Atoi(query.Get("cursor")) + page := matching[min(offset, len(matching)):min(offset+limit, len(matching))] + f.appIDPages++ + for _, identifier := range page { + appID := f.appIDs[identifier] capabilities := []map[string]any{{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE"}} included = append(included, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE", "attributes": map[string]any{"capabilityType": "IN_APP_PURCHASE"}}) if appID.push { @@ -355,7 +369,11 @@ func (f *fakeApple) connectAPI(w http.ResponseWriter, r *http.Request, path stri "relationships": map[string]any{"bundleIdCapabilities": map[string]any{"data": capabilities}}, }) } - writeJSON(w, http.StatusOK, map[string]any{"data": data, "included": included}) + document := map[string]any{"data": data, "included": included} + if offset+limit < len(matching) { + document["links"] = map[string]any{"next": fmt.Sprintf("https://developer.apple.com/services-account/v1/bundleIds?cursor=%d&limit=%d", offset+limit, limit)} + } + writeJSON(w, http.StatusOK, document) case path == "bundleIds" && r.Method == http.MethodPost: data, _ := params["data"].(map[string]any) attributes, _ := data["attributes"].(map[string]any) @@ -877,4 +895,20 @@ func TestPrepareAppIDWithoutASavedSignInAsksNothing(t *testing.T) { t.Errorf("asked %v, requests %v", asker.asked, fake.requests) } } +func TestPrepareAppIDFindsTheAppIDOnALaterPage(t *testing.T) { + fake := newFakeApple(t) + // More longer identifiers than one page, listed before the exact one. + for index := range 250 { + fake.appIDs[fmt.Sprintf("com.example.app.widget%03d", index)] = &fakeAppID{id: fmt.Sprintf("A%03d", index)} + } + fake.appIDs["com.example.app"] = &fakeAppID{id: "Z", push: true} + + result, err := prepareAppID(t, fake, &scriptedAsker{}, true) + if err != nil { + t.Fatal(err) + } + if result.Created || !result.Registered || !result.Push || fake.appIDPages != 2 { + t.Errorf("result = %+v after %d pages", result, fake.appIDPages) + } +} {% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/appid.go b/templates/cli/internal/apns/appwrite/appid.go index 86ef6a0ed5..ee586a24f4 100644 --- a/templates/cli/internal/apns/appwrite/appid.go +++ b/templates/cli/internal/apns/appwrite/appid.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" ) {% verbatim %} @@ -16,7 +17,13 @@ import ( // carry X-HTTP-Method-Override: GET and the query in the body, and every // write carries the team in its attributes. -const pushCapability = "PUSH_NOTIFICATIONS" +const ( + pushCapability = "PUSH_NOTIFICATIONS" + appIDsPageSize = 200 + // maxAppIDPages bounds the lookup should the portal keep handing out + // next pages. + maxAppIDPages = 100 +) // AppID is a registered App ID and whether it can receive pushes. type AppID struct { @@ -38,7 +45,12 @@ type jsonAPIResource struct { type jsonAPIDocument struct { Data json.RawMessage `json:"data"` Included []jsonAPIResource `json:"included"` - Errors []struct { + // Links.Next is the next page of a list, as a URL whose query carries + // the cursor. + Links struct { + Next string `json:"next"` + } `json:"links"` + Errors []struct { Status string `json:"status"` Code string `json:"code"` Title string `json:"title"` @@ -99,16 +111,38 @@ func (p *portalClient) connectAPI(ctx context.Context, method, path, teamID stri } // appID looks up identifier among the team's App IDs. It returns nil when -// the team has none with that identifier. +// the team has none with that identifier. The portal's filter also returns +// longer identifiers that start with it, so every page is read until the +// exact one turns up. func (p *portalClient) appID(ctx context.Context, teamID, identifier string) (*AppID, error) { query := url.Values{} query.Set("filter[identifier]", identifier) query.Set("include", "bundleIdCapabilities") - query.Set("limit", "200") - document, err := p.connectAPI(ctx, http.MethodGet, "bundleIds", teamID, query, nil) - if err != nil { - return nil, err + query.Set("limit", strconv.Itoa(appIDsPageSize)) + for page := 0; page < maxAppIDPages; page++ { + document, err := p.connectAPI(ctx, http.MethodGet, "bundleIds", teamID, query, nil) + if err != nil { + return nil, err + } + if found, err := exactAppID(document, identifier); found != nil || err != nil { + return found, err + } + if document.Links.Next == "" { + return nil, nil + } + next, err := url.Parse(document.Links.Next) + if err != nil || next.Query().Get("cursor") == "" { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable next page link %q", document.Links.Next) + } + query.Set("cursor", next.Query().Get("cursor")) } + + return nil, fmt.Errorf("could not find %s within %d pages of App IDs", identifier, maxAppIDPages) +} + +// exactAppID returns the App ID with exactly identifier on one page of the +// list, or nil. +func exactAppID(document *jsonAPIDocument, identifier string) (*AppID, error) { var resources []jsonAPIResource if err := json.Unmarshal(document.Data, &resources); err != nil { return nil, fmt.Errorf("the Apple Developer portal sent an unreadable App ID list: %w", err) From 7b939efc3804bd37e73ac36bcf07e937c0a614b1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 18:33:23 +0530 Subject: [PATCH 07/12] fix(cli): reuse the new key's Apple sign-in for the App ID check After --create-key the App ID step signed in from scratch, so the expo and fastlane setups asked for the Apple ID and password again, and fastlane once more for the change after the question. Keys now carry the Apple ID that signed in; the check right after a new key uses that saved sign-in only, and signs in again without asking only if it cannot. In the expo and fastlane setups the change after the question reuses the check's saved sign-in too. --- templates/cli/internal/apns/apns.go | 3 ++ .../cli/internal/apns/apnstest/apnstest.go | 7 +++- .../cli/internal/apns/appwrite/adapter.go | 4 +- .../internal/apns/appwrite/adapter_test.go | 2 +- templates/cli/internal/apns/appwrite/auth.go | 3 ++ templates/cli/internal/apns/expo/adapter.go | 13 ++++++- .../cli/internal/apns/expo/adapter_test.go | 7 ++-- .../cli/internal/apns/expo/helper/helper.js | 15 +++++--- .../cli/internal/apns/fastlane/adapter.go | 38 +++++++++++-------- .../internal/apns/fastlane/adapter_test.go | 5 ++- templates/cli/internal/cmd/initpush.go | 22 +++++++++-- templates/cli/internal/cmd/initpush_test.go | 24 ++++++++++-- 12 files changed, 106 insertions(+), 37 deletions(-) diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go index 5cb0a10bc6..fe27d6f8f5 100644 --- a/templates/cli/internal/apns/apns.go +++ b/templates/cli/internal/apns/apns.go @@ -48,6 +48,9 @@ type Key struct { KeyID string TeamID string P8 string + // AppleID is the Apple ID that signed in, when the adapter knows it, so + // later steps can reuse the saved sign-in instead of asking again. + AppleID string } // ExistingKey is a key already on the team. diff --git a/templates/cli/internal/apns/apnstest/apnstest.go b/templates/cli/internal/apns/apnstest/apnstest.go index ef3ed1af0c..763c166f2c 100644 --- a/templates/cli/internal/apns/apnstest/apnstest.go +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -55,7 +55,12 @@ func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, team = a.TeamID } - return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8()}, nil + appleID := request.AppleID + if appleID == "" { + appleID = "dev@example.com" + } + + return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8(), AppleID: appleID}, nil } // PrepareAppID records the call and returns AppIDResult or AppIDErr. diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go index 3b4515c93f..51eaef7975 100644 --- a/templates/cli/internal/apns/appwrite/adapter.go +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -72,7 +72,7 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key return apns.Key{}, fmt.Errorf("created key %s but could not download it, and Apple allows only one download. Revoke it in the Apple Developer portal: %w", keyID, err) } - return apns.Key{KeyID: keyID, TeamID: teamID, P8: p8}, nil + return apns.Key{KeyID: keyID, TeamID: teamID, P8: p8, AppleID: portal.appleID}, nil } // PrepareAppID signs in, then makes sure the bundle ID is an App ID with Push @@ -185,6 +185,7 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli if session, err := portal.session(ctx); err == nil && session != nil { if accountName == "" || strings.EqualFold(session.User.EmailAddress, accountName) { logf("Using the saved Apple sign-in for %s.", session.User.EmailAddress) + portal.appleID = session.User.EmailAddress return portal, nil } @@ -231,6 +232,7 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli if session == nil { return nil, errors.New("Apple signed you in but App Store Connect refused the session. Sign in once at https://appstoreconnect.apple.com to accept any pending agreements, then try again") } + portal.appleID = accountName return portal, nil } diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index f53680a0da..27225f1c3a 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -487,7 +487,7 @@ func TestCreateKeySignsInWithTwoFactorAndReusesTheSession(t *testing.T) { if err != nil { t.Fatal(err) } - if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 { + if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 || key.AppleID != testAppleID { t.Errorf("key = %+v", key) } created := fake.created["NEWKEY1234"] diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go index a912188c89..dbd974f07a 100644 --- a/templates/cli/internal/apns/appwrite/auth.go +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -47,6 +47,9 @@ type portalClient struct { random io.Reader now func() time.Time + // appleID is the Apple ID of the signed-in session. + appleID string + widgetKey string sessionID string scnt string diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index 2d2e8b33ae..c2f9146852 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -63,6 +63,7 @@ func (a *Adapter) Name() string { type result struct { KeyID string `json:"keyId"` + AppleID string `json:"appleId"` TeamID string `json:"teamId"` P8 string `json:"p8"` Error string `json:"error"` @@ -119,6 +120,12 @@ func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID if err != nil { return apns.AppIDResult{}, err } + // The apply run restores the check's sign-in, which apple-utils keeps + // per Apple ID. + var signedIn result + if json.Unmarshal(contents, &signedIn) == nil && signedIn.AppleID != "" { + request.AppleID = signedIn.AppleID + } return decodeAppIDResult(contents) } @@ -133,9 +140,11 @@ func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID if err != nil || !change { return found, err } - // The check signed in (again, for a reset) and saved the session. + // The check signed in (again, for a reset) and saved the session, so the + // change reuses it instead of asking again. request.Reset = false request.TeamID = found.TeamID + request.SavedSignInOnly = true return run("apply") } @@ -271,7 +280,7 @@ func decodeResult(contents []byte) (apns.Key, error) { return apns.Key{}, errors.New("the @expo/apple-utils helper returned no key") } - return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8, AppleID: decoded.AppleID}, nil } func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go index 96f373e50c..73e7894ff2 100644 --- a/templates/cli/internal/apns/expo/adapter_test.go +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -31,8 +31,8 @@ func TestSupportedNode(t *testing.T) { } func TestDecodeResult(t *testing.T) { - key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----"}`)) - if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----","appleId":"dev@example.com"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" || key.AppleID != "dev@example.com" { t.Errorf("key = %+v, %v", key, err) } @@ -294,7 +294,8 @@ func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { } calls := helperCalls(t, log) if len(calls) != 2 || !strings.Contains(calls[0], "--mode check") || !strings.Contains(calls[0], "--reset true") || - !strings.Contains(calls[1], "--mode apply") || !strings.Contains(calls[1], "--team-id ABCDE12345") || strings.Contains(calls[1], "--reset") { + !strings.Contains(calls[1], "--mode apply") || !strings.Contains(calls[1], "--team-id ABCDE12345") || strings.Contains(calls[1], "--reset") || + !strings.Contains(calls[1], "--saved-only true") || !strings.HasSuffix(calls[1], "EXPO_APPLE_ID=dev@example.com") { t.Errorf("helper calls = %v", calls) } if len(asker.asked) != 1 || !strings.HasPrefix(asker.asked[0], "io.example.app is not registered as an App ID on team ABCDE12345") { diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js index 4fcdd51a82..6fc8ea96a9 100644 --- a/templates/cli/internal/apns/expo/helper/helper.js +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -45,7 +45,7 @@ function isMaxKeysError(error) { ); } -// signIn returns the request context, or null when --saved-only finds no +// signIn returns the signed-in auth state, or null when --saved-only finds no // saved sign-in for EXPO_APPLE_ID. async function signIn({ 'team-id': teamId, reset, 'saved-only': savedOnly }) { // --reset deletes apple-utils' saved session for this Apple ID first, so the @@ -71,7 +71,7 @@ async function signIn({ 'team-id': teamId, reset, 'saved-only': savedOnly }) { throw new Error(`Signed in to Apple team ${context.teamId}, not ${teamId}`); } - return context; + return authState; } async function createKey(context, { name }) { @@ -129,12 +129,17 @@ async function prepareAppId(context, { 'bundle-id': identifier, name, mode }) { } async function run(options) { - const context = await signIn(options); - if (!context) { + const authState = await signIn(options); + if (!authState) { return { error: 'sign-in-needed' }; } + const result = + options.action === 'app-id' + ? await prepareAppId(authState.context, options) + : await createKey(authState.context, options); - return options.action === 'app-id' ? prepareAppId(context, options) : createKey(context, options); + // The Apple ID lets later runs reuse this sign-in without asking. + return { ...result, appleId: authState.username }; } async function main() { diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index ef5f8066d4..d375cdc507 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -85,12 +85,14 @@ type result struct { // CreateKey runs the embedded create_apns_key lane. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { arguments := []string{"name:" + request.Name, "environment:" + string(request.Environment)} - contents, err := a.runLane(ctx, request, "create_apns_key", arguments...) + contents, appleID, err := a.runLane(ctx, request, "create_apns_key", arguments...) if err != nil { return apns.Key{}, err } + key, err := decodeResult(contents) + key.AppleID = appleID - return decodeResult(contents) + return key, err } // PrepareAppID runs the embedded prepare_app_id lane: it checks the App ID, @@ -107,10 +109,12 @@ func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID if request.SavedSignInOnly { arguments = append(arguments, "saved_only:true") } - contents, err := a.runLane(ctx, request, "prepare_app_id", arguments...) + contents, appleID, err := a.runLane(ctx, request, "prepare_app_id", arguments...) if err != nil { return apns.AppIDResult{}, err } + // The apply run reuses the check's sign-in instead of asking again. + request.AppleID = appleID return decodeAppIDResult(contents) } @@ -125,23 +129,25 @@ func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID if err != nil || !change { return found, err } - // The check signed in (again, for a reset) and saved the session. + // The check signed in (again, for a reset) and saved the session, so the + // change reuses it instead of asking again. request.Reset = false request.TeamID = found.TeamID + request.SavedSignInOnly = true return run("apply") } // runLane runs a lane of the embedded Fastfile and returns the result it -// wrote. The Apple ID and password are asked here when the request has none, +// wrote, with the Apple ID it ran as. The Apple ID and password are asked here when the request has none, // and handed to fastlane as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane // never offers to keep them in the Keychain. With SavedSignInOnly nothing is // asked: the lane uses spaceship's saved session for the Apple ID or reports // that there is none. -func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string, arguments ...string) ([]byte, error) { +func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string, arguments ...string) ([]byte, string, error) { runner, err := a.runner() if err != nil { - return nil, err + return nil, "", err } err = runner.check(ctx) // Installing is only offered for fastlane from PATH, never replacing a @@ -152,7 +158,7 @@ func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string } } if err != nil { - return nil, err + return nil, "", err } if runner.gemfile != "" && request.Log != nil { request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) @@ -162,7 +168,7 @@ func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string if appleID == "" { answer, err := request.Asker.Ask("Apple ID (email)", false) if err != nil { - return nil, err + return nil, "", err } appleID = strings.TrimSpace(answer) } @@ -170,7 +176,7 @@ func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string if password == "" && !request.SavedSignInOnly { answer, err := request.Asker.Ask("Password for "+appleID, true) if err != nil { - return nil, err + return nil, "", err } password = answer } @@ -178,14 +184,14 @@ func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string // fastlane runs the Fastfile in ./fastlane of its working directory. scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") if err != nil { - return nil, err + return nil, "", err } defer os.RemoveAll(scratch) if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { - return nil, err + return nil, "", err } if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { - return nil, err + return nil, "", err } out := filepath.Join(scratch, "result.json") @@ -213,13 +219,13 @@ func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string contents, err := os.ReadFile(out) if err != nil { if runErr != nil { - return nil, fmt.Errorf("the fastlane lane failed: %w", runErr) + return nil, "", fmt.Errorf("the fastlane lane failed: %w", runErr) } - return nil, fmt.Errorf("the fastlane lane returned no result: %w", err) + return nil, "", fmt.Errorf("the fastlane lane returned no result: %w", err) } - return contents, nil + return contents, appleID, nil } func decodeAppIDResult(contents []byte) (apns.AppIDResult, error) { diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 2d8934fff4..0ba41ab71d 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -119,7 +119,7 @@ func TestCreateKeyRunsTheLane(t *testing.T) { Environment: apns.EnvironmentSandbox, Asker: asker, }) - if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" || key.AppleID != "dev@example.com" { t.Fatalf("key = %+v, %v", key, err) } if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for dev@example.com" { @@ -496,7 +496,8 @@ func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { } calls := laneCalls(t, log) if len(calls) != 2 || !strings.Contains(calls[0], "mode:check") || !strings.Contains(calls[0], "reset:true") || - !strings.Contains(calls[1], "mode:apply") || !strings.Contains(calls[1], "team_id:ABCDE12345") || strings.Contains(calls[1], "reset:true") { + !strings.Contains(calls[1], "mode:apply") || !strings.Contains(calls[1], "team_id:ABCDE12345") || strings.Contains(calls[1], "reset:true") || + !strings.Contains(calls[1], "saved_only:true") { t.Errorf("lane calls = %v", calls) } if strings.Join(asker.asked, "|") != "Push Notifications is off for io.example.app, so APNs refuses pushes for it. Turn it on?" { diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index dbaffe58c0..691f0b708e 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -90,6 +90,9 @@ type pushSetup struct { // apnsSessions is where they keep a signed-in session. apnsAdapters *apns.Registry apnsSessions string + // appleID is the Apple ID a key was just created with, so the App ID + // check reuses that sign-in. + appleID string } func newPushSetup(command *cobra.Command) (*pushSetup, error) { @@ -722,14 +725,24 @@ func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions, s } output.Log(s.out, "Checking the App ID for %s ...", bundleID) - // A key created a moment ago left a fresh sign-in: --reset is not repeated. + // A key created a moment ago left a fresh, saved sign-in: the check reuses + // it for the same Apple ID, and --reset is not repeated. request := s.appleRequest(adapter, teamID, options.reset && !options.createKey) - request.SavedSignInOnly = !signedIn && !explicit - if explicit && !signedIn { + if signedIn && request.AppleID == "" { + request.AppleID = s.appleID + } + request.SavedSignInOnly = signedIn || !explicit + if !request.SavedSignInOnly { s.logAppleSignIn(adapter, request) } appID := apns.AppIDRequest{BundleID: bundleID, Ask: !explicit} result, err := apns.PrepareAppID(s.context(), adapter, request, appID) + // Just signed in, or asked for explicitly: sign in again without asking. + if errors.Is(err, apns.ErrSignInNeeded) && (signedIn || explicit) { + request.SavedSignInOnly = false + s.logAppleSignIn(adapter, request) + result, err = apns.PrepareAppID(s.context(), adapter, request, appID) + } if errors.Is(err, apns.ErrSignInNeeded) { answer, askErr := s.prompter.Choice(prompt.Choice{ Message: "Sign in with your Apple ID to check the App ID for " + bundleID + "?", @@ -802,6 +815,9 @@ func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environmen request.Name = "Appwrite Push " + time.Now().Format("20060102150405") request.Environment = environment key, err := apns.CreateKey(s.context(), adapter, request) + if err == nil { + s.appleID = key.AppleID + } var unanswered *prompt.NonInteractiveError if errors.As(err, &unanswered) { return "", "", "", fmt.Errorf("signing in to Apple asked %q, which needs an interactive terminal. %s and %s answer the Apple ID and password, but the two-factor code always needs one. Without a terminal, pass --key-path with an existing .p8 instead of --create-key", unanswered.Message, appleIDVariable, applePasswordVariable) diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 5c93fa21b9..1bb186625a 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -1745,9 +1745,9 @@ func TestInitApnsChecksTheAppIDAfterCreatingAKey(t *testing.T) { if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0] != (apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) { t.Fatalf("App ID calls = %v", adapter.AppIDRequests) } - // The key's sign-in is reused: no question, and --reset does not sign in - // a second time. - if signIn := adapter.AppIDSignIns[0]; signIn.TeamID != "ABCDE12345" || signIn.Reset || signIn.SavedSignInOnly { + // The key's saved sign-in is reused for its Apple ID: no question, and + // --reset does not sign in a second time. + if signIn := adapter.AppIDSignIns[0]; signIn.TeamID != "ABCDE12345" || signIn.Reset || !signIn.SavedSignInOnly || signIn.AppleID != "dev@example.com" { t.Errorf("App ID sign-in = %+v", signIn) } if len(scripted.Asked) != 0 { @@ -1856,3 +1856,21 @@ func TestInitApnsRegisterAppIDSignsInAndDoesNotAsk(t *testing.T) { t.Errorf("err = %v", err) } } + +func TestInitApnsSignsInAgainWhenTheNewKeysSignInIsNotSaved(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234", SignInNeeded: true}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + // The person just signed in for the key: no "sign in to check?" question. + if len(adapter.AppIDSignIns) != 2 || !adapter.AppIDSignIns[0].SavedSignInOnly || adapter.AppIDSignIns[1].SavedSignInOnly || len(scripted.Asked) != 0 { + t.Errorf("sign-ins = %+v, asked %v", adapter.AppIDSignIns, scripted.Asked) + } +} From 1942cc04d96e26f9e93fa4c7374e4f28bc4dfc68 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 15:22:40 +0530 Subject: [PATCH 08/12] fix(cli): check --bundle-id and --provider before init apns changes anything A bundle ID with spaces created APNs providers for it, and an unknown --provider was only reported at the App ID check, after the providers were written. Both now fail first. --- templates/cli/internal/apns/apns.go | 13 +++++++-- templates/cli/internal/cmd/initpush.go | 8 +++++- templates/cli/internal/cmd/initpush_test.go | 31 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go index fe27d6f8f5..5b24baf5ea 100644 --- a/templates/cli/internal/apns/apns.go +++ b/templates/cli/internal/apns/apns.go @@ -181,11 +181,20 @@ func AppIDName(bundleID string) string { var bundleIDPattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`) +// CheckBundleID reports whether bundleID is shaped like an iOS bundle ID. +func CheckBundleID(bundleID string) error { + if !bundleIDPattern.MatchString(bundleID) { + return fmt.Errorf("%q is not a bundle ID: a bundle ID has letters, digits, hyphens and dots", bundleID) + } + + return nil +} + // PrepareAppID checks the request, runs the adapter, and checks what it // returned, so every adapter is held to the same contract. func PrepareAppID(ctx context.Context, adapter Adapter, request Request, appID AppIDRequest) (AppIDResult, error) { - if !bundleIDPattern.MatchString(appID.BundleID) { - return AppIDResult{}, fmt.Errorf("%q is not a bundle ID: a bundle ID has letters, digits, hyphens and dots", appID.BundleID) + if err := CheckBundleID(appID.BundleID); err != nil { + return AppIDResult{}, err } if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) { return AppIDResult{}, fmt.Errorf("%q is not an Apple team ID: a team ID is 10 uppercase letters or digits", request.TeamID) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 691f0b708e..11f86293a3 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -393,11 +393,17 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { if err != nil { return err } + // A setup chosen by name must exist before anything is written. + if options.setup != "" || s.env(apnsSetupVariable) != "" { + if _, err := s.apnsAdapter(options.setup); err != nil { + return err + } + } wantProduction := environment != apns.EnvironmentSandbox wantSandbox := environment != apns.EnvironmentProduction bundleID, err := s.pick(options.bundleID, detected.BundleIDs, - "Which iOS bundle ID should receive push notifications?", "--bundle-id", requiredValue("bundle ID")) + "Which iOS bundle ID should receive push notifications?", "--bundle-id", apns.CheckBundleID) if err != nil { return err } diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 1bb186625a..cac38945be 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -1558,6 +1558,37 @@ func TestInitApnsPicksTheSetupFromTheFlagThenTheEnvironment(t *testing.T) { } } +func TestInitApnsRejectsAMalformedBundleID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + err := setup.apns(apnsOptions{bundleID: "not a bundle id!", environment: "sandbox"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "is not a bundle ID") { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsRejectsAnUnknownSetupBeforeAnyRequest(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(&apnstest.Adapter{AdapterName: "appwrite"}) + err := setup.apns(apnsOptions{bundleID: "com.example.app", environment: "all", setup: "nope"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), `unknown APNs key setup "nope"`) { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + func TestInitApnsReportsAnUnknownSetup(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) From 1d2fdd6bcc4dad6ba21aecd060881fb5461cb4fd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 15:22:57 +0530 Subject: [PATCH 09/12] fix(cli): the same message in every setup when signing in to Apple needs a terminal The App ID check told people to pass --register-app-id even when they had, and the expo setup failed with apple-utils' own message naming EXPO_APPLE_ID. Every setup now names APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD, as key creation already did. --- templates/cli/internal/apns/expo/adapter.go | 5 ++++- templates/cli/internal/cmd/initpush.go | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index c2f9146852..909f7752e2 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -25,6 +25,7 @@ import ( "strings" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" ) //go:embed helper/helper.js helper/package.json helper/package-lock.json @@ -160,7 +161,9 @@ func (a *Adapter) runHelper(ctx context.Context, request *apns.Request, argument // For a reset the helper signs out of apple-utils before signing in. Its // sign out asks for the Apple ID and forgets it, so the sign-in would ask // again; asking here once and passing it to both avoids that. - if request.Reset && strings.TrimSpace(request.AppleID) == "" && request.Asker != nil { + // Without a terminal, asking here fails the same way the other setups do, + // instead of with apple-utils' own message. + if (request.Reset || !prompt.Interactive()) && !request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" && request.Asker != nil { answer, err := request.Asker.Ask("Apple ID (email)", false) if err != nil { return nil, err diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 11f86293a3..45c97b9c25 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -769,7 +769,7 @@ func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions, s } var unanswered *prompt.NonInteractiveError if errors.As(err, &unanswered) { - return fail("Could not check the App ID for %s: %q needs an interactive terminal. Pass --register-app-id to register it without asking", bundleID, unanswered.Message) + return fail("Could not check the App ID for %s: %s", bundleID, needsTerminal(unanswered)) } if err != nil { return fail("Could not check the App ID for %s: %s", bundleID, err) @@ -793,6 +793,11 @@ func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions, s return nil } +// needsTerminal says that signing in to Apple asked a question without a terminal. +func needsTerminal(unanswered *prompt.NonInteractiveError) string { + return fmt.Sprintf("signing in to Apple asked %q, which needs an interactive terminal. %s and %s answer the Apple ID and password, but the two-factor code always needs one", unanswered.Message, appleIDVariable, applePasswordVariable) +} + // provisionedTeam is the team of an existing APNs provider for the bundle ID, // else the one detected in the project, else none (signing in picks it). func provisionedTeam(production, sandbox *messagingProvider, detected []string) string { @@ -826,7 +831,7 @@ func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environmen } var unanswered *prompt.NonInteractiveError if errors.As(err, &unanswered) { - return "", "", "", fmt.Errorf("signing in to Apple asked %q, which needs an interactive terminal. %s and %s answer the Apple ID and password, but the two-factor code always needs one. Without a terminal, pass --key-path with an existing .p8 instead of --create-key", unanswered.Message, appleIDVariable, applePasswordVariable) + return "", "", "", fmt.Errorf("%s. Without a terminal, pass --key-path with an existing .p8 instead of --create-key", needsTerminal(unanswered)) } if err != nil { return "", "", "", err From ded77988341f78948f5382cbcb81abdd7106c752 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 15:22:57 +0530 Subject: [PATCH 10/12] fix(cli): say why the App ID check was skipped Without a terminal and with no saved Apple sign-in, the check was skipped with no reason given. --- templates/cli/internal/cmd/initpush.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 45c97b9c25..1dabb7c128 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -759,7 +759,11 @@ func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions, s }, }) if askErr != nil || answer != "check" { - output.Hint(s.out, "Skipped the App ID check. Run `appwrite init apns --register-app-id`, or check %s at %s.", bundleID, appIDsPage) + reason := "" + if askErr != nil { + reason = ": there is no saved Apple sign-in, and no terminal to sign in" + } + output.Hint(s.out, "Skipped the App ID check%s. Run `appwrite init apns --register-app-id` in a terminal, or check %s at %s.", reason, bundleID, appIDsPage) return nil } From 928a21aa10ba430782fcf6e3cc18b94c6a279105 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 15:22:57 +0530 Subject: [PATCH 11/12] fix(cli): keep fastlane's log out of a saved sign-in check A check that only uses the saved sign-in asks nothing, but printed the lane's log, including 'Continuing with normal login', before reporting that it was skipped. The log is now shown only when the lane fails. --- templates/cli/internal/apns/fastlane/adapter.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index d375cdc507..9743caea1d 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -9,6 +9,7 @@ package fastlane import ( + "bytes" "context" _ "embed" "encoding/json" @@ -204,7 +205,13 @@ func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string } command := runner.command(ctx, arguments...) command.Dir = scratch - command.Stdin, command.Stdout, command.Stderr = a.terminal() + stdin, stdout, stderr := a.terminal() + // A saved sign-in asks nothing, so fastlane's log is shown only if the lane fails. + var log bytes.Buffer + if request.SavedSignInOnly { + stdout, stderr = &log, &log + } + command.Stdin, command.Stdout, command.Stderr = stdin, stdout, stderr command.Env = append(command.Env, "FASTLANE_USER="+appleID, "FASTLANE_SKIP_UPDATE_CHECK=1", @@ -215,6 +222,10 @@ func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string command.Env = append(command.Env, "FASTLANE_PASSWORD="+password) } runErr := command.Run() + if runErr != nil && log.Len() > 0 { + _, _, terminalErr := a.terminal() + _, _ = log.WriteTo(terminalErr) + } contents, err := os.ReadFile(out) if err != nil { From 8bb3adba81aec545b8cac14f6e1fdcb681e19d6a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 15:22:57 +0530 Subject: [PATCH 12/12] fix(cli): name the aps-environment entitlement in the Expo hint For an Expo app, init apns hinted only the background mode, while an Xcode project also gets the aps-environment entitlement. --- templates/cli/internal/cmd/initpush.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 1dabb7c128..bc9e767a1a 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -1525,7 +1525,7 @@ func (s *pushSetup) configureXcode(detected appleApp, bundleID string) { output.Hint(s.out, "In Xcode, add Background Modes and tick Remote notifications.") } if detected.ExpoConfig != "" { - output.Hint(s.out, "Expo regenerates ios/ on prebuild. To keep these settings, add \"remote-notification\" to expo.ios.infoPlist.UIBackgroundModes in %s.", + output.Hint(s.out, "Expo regenerates ios/ on prebuild. In %s, set expo.ios.entitlements[\"aps-environment\"] to \"development\" and add \"remote-notification\" to expo.ios.infoPlist.UIBackgroundModes.", filepath.Base(detected.ExpoConfig)) } }