From 40347a2028f681b35e8b93344d6c2f2084ce2153 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 5 Oct 2026 16:16:54 +0530 Subject: [PATCH 01/29] feat(cli): add init apns and init push on top of init fcm --- templates/cli/internal/cmd/init.go | 2 + templates/cli/internal/cmd/initpush.go | 457 +++++++++++++++++++ templates/cli/internal/cmd/initpush_test.go | 335 +++++++++++++- templates/cli/internal/cmd/initpushdetect.go | 237 ++++++++++ 4 files changed, 1021 insertions(+), 10 deletions(-) diff --git a/templates/cli/internal/cmd/init.go b/templates/cli/internal/cmd/init.go index c36d053510..cec5d6c9e2 100644 --- a/templates/cli/internal/cmd/init.go +++ b/templates/cli/internal/cmd/init.go @@ -38,6 +38,8 @@ func newInitCommand() *cobra.Command { newInitFunctionCommand(), newInitSiteCommand(), newInitSkillCommand(), + newInitPushCommand(), + newInitApnsCommand(), newInitFcmCommand(), newInitBucketCommand(), newInitTeamCommand(), diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 0dd1f90b11..ee950b1551 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -4,6 +4,8 @@ package cmd import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rsa" "crypto/x509" "encoding/json" @@ -15,6 +17,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "time" @@ -26,6 +29,7 @@ import ( ) const ( + apnsKeyPage = "https://developer.apple.com/account/resources/authkeys/add" 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" @@ -34,6 +38,18 @@ const ( firebaseListTimeout = 30 * time.Second ) +var ( + apnsKeyFileName = regexp.MustCompile(`^AuthKey_([A-Z0-9]{10})\.p8$`) + apnsKeyID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +type apnsOptions struct { + keyPath string + keyID string + teamID string + bundleID string +} + type fcmOptions struct { keyPath string projectID string @@ -88,6 +104,34 @@ func newPushSetup(command *cobra.Command) (*pushSetup, error) { }, nil } +func newInitApnsCommand() *cobra.Command { + options := apnsOptions{} + command := &cobra.Command{ + Use: "apns", + Short: "Set up Apple Push Notification service for Appwrite Push", + RunE: func(command *cobra.Command, args []string) error { + setup, err := newPushSetup(command) + if err != nil { + return err + } + detected, err := detectApple(setup.root) + if err != nil { + return err + } + + return setup.apns(options, detected) + }, + } + + flags := command.Flags() + flags.StringVar(&options.keyPath, "key-path", "", "Path to an APNs auth key (.p8)") + flags.StringVar(&options.keyID, "key-id", "", "APNs key ID") + flags.StringVar(&options.teamID, "team-id", "", "Apple Developer team ID") + flags.StringVar(&options.bundleID, "bundle-id", "", "iOS app bundle ID") + + return command +} + func newInitFcmCommand() *cobra.Command { options := fcmOptions{} command := &cobra.Command{ @@ -115,6 +159,90 @@ func newInitFcmCommand() *cobra.Command { return command } +func newInitPushCommand() *cobra.Command { + var skipApns, skipFcm bool + command := &cobra.Command{ + Use: "push", + Short: "Set up background delivery for Appwrite Push on every platform in this project", + RunE: func(command *cobra.Command, args []string) error { + setup, err := newPushSetup(command) + if err != nil { + return err + } + + return setup.push(skipApns, skipFcm) + }, + } + + command.Flags().BoolVar(&skipApns, "skip-apns", false, "Do not set up APNs") + command.Flags().BoolVar(&skipFcm, "skip-fcm", false, "Do not set up FCM") + + return command +} + +func (s *pushSetup) push(skipApns, skipFcm bool) error { + apple, err := detectApple(s.root) + if err != nil { + return err + } + android, err := detectAndroid(s.root) + if err != nil { + return err + } + + runApns := apple.found() && !skipApns + runFcm := android.found() && !skipFcm + + fmt.Fprintln(s.out, output.Heading("Detected")) + fmt.Fprintf(s.out, " iOS %s\n", describeApple(apple, skipApns)) + fmt.Fprintf(s.out, " Android %s\n\n", describeAndroid(android, skipFcm)) + + if !runApns && !runFcm { + output.Log(s.out, "No iOS or Android app found here.") + output.Hint(s.out, "Run '%s init apns' or '%s init fcm' with --key-path to set up a platform directly.", + app.ExecutableName, app.ExecutableName) + + return nil + } + + var failures []string + if runApns { + if err := s.apns(apnsOptions{}, apple); err != nil { + s.reportStep("APNs", "apns", err) + failures = append(failures, "APNs") + } + } + if runFcm { + if runApns { + fmt.Fprintln(s.out) + } + if err := s.fcm(fcmOptions{}, android); err != nil { + s.reportStep("FCM", "fcm", err) + failures = append(failures, "FCM") + } + } + + if len(failures) > 0 { + return fmt.Errorf("%s setup did not finish", strings.Join(failures, " and ")) + } + + return nil +} + +func (s *pushSetup) reportStep(name, command string, err error) { + if errors.Is(err, prompt.ErrAborted) { + return + } + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) && unanswered.Flag != "" { + output.Failure(s.out, "%s: %q needs an answer but there is no interactive terminal", name, unanswered.Message) + output.Hint(s.out, "Run '%s init %s %s ' instead.", app.ExecutableName, command, unanswered.Flag) + + return + } + output.Failure(s.out, "%s: %s", name, err) +} + func describeFirebase(configs []firebaseConfig) string { parts := make([]string, 0, len(configs)) for _, config := range configs { @@ -128,6 +256,42 @@ func describeFirebase(configs []firebaseConfig) string { return strings.Join(parts, "; ") } +func describeApple(apple appleApp, skipped bool) string { + switch { + case !apple.found(): + return "not found" + case skipped: + return "skipped (--skip-apns)" + case len(apple.BundleIDs) == 0: + return "found, bundle ID unknown" + } + + return strings.Join(apple.BundleIDs, ", ") +} + +func describeAndroid(android androidApp, skipped bool) string { + switch { + case !android.found(): + return "not found" + case skipped: + return "skipped (--skip-fcm)" + } + + description := strings.Join(android.ApplicationIDs, ", ") + if description == "" { + description = "found, application ID unknown" + } + if projects := android.projectIDs(); len(projects) > 0 { + label := " (Firebase project " + if len(projects) > 1 { + label = " (Firebase projects " + } + description += label + strings.Join(projects, ", ") + ")" + } + + return description +} + func (s *pushSetup) pick(value string, detected []string, question, flag string, validate func(string) error) (string, error) { if value != "" { if validate != nil { @@ -166,6 +330,118 @@ func requiredValue(name string) func(string) error { } } +func matchesPattern(pattern *regexp.Regexp, description string) func(string) error { + return func(value string) error { + if !pattern.MatchString(value) { + return fmt.Errorf("%s", description) + } + + return nil + } +} + +func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { + output.Log(s.out, "Setting up APNs ...") + + bundleID, err := s.pick(options.bundleID, detected.BundleIDs, + "Which iOS bundle ID should receive push notifications?", "--bundle-id", requiredValue("bundle ID")) + if err != nil { + return err + } + + existing, err := s.providers("apns") + if err != nil { + return err + } + production := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && !provider.sandbox() + }) + sandbox := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && provider.sandbox() + }) + if production != nil && sandbox != nil && production.Enabled && sandbox.Enabled && options.keyPath == "" && !app.Flags().Force { + output.Success(s.out, "APNs is already set up for %s.", bundleID) + output.Hint(s.out, "Pass --force or --key-path to replace the key.") + s.configureXcode(detected, bundleID) + + return nil + } + + teams := detected.teams(bundleID) + for _, provider := range []*messagingProvider{production, sandbox} { + if provider != nil && len(teams) == 0 && appleTeamID.MatchString(provider.credential("teamId")) { + teams = []string{provider.credential("teamId")} + } + } + teamID, err := s.pick(options.teamID, teams, + "What is your Apple Developer team ID?", "--team-id", + matchesPattern(appleTeamID, "a team ID is 10 uppercase letters or digits")) + if err != nil { + return err + } + + keyPath := options.keyPath + if keyPath == "" { + keyPath, _, err = s.acquireKey("APNs auth key (.p8)", "", "Create one in the Apple Developer portal", "--key-path", apnsKeyPage, + "In the Apple Developer portal, create a key with Apple Push Notifications service (APNs) enabled and download it.", + func(path string) bool { + _, err := readApnsKey(path) + + return strings.HasSuffix(path, ".p8") && err == nil + }) + if err != nil { + return err + } + } + + key, err := readApnsKey(keyPath) + if err != nil { + return err + } + if options.keyPath != "" { + s.checkKeyLocation(expandHome(keyPath)) + } + + keyID := options.keyID + if keyID == "" { + if match := apnsKeyFileName.FindStringSubmatch(filepath.Base(keyPath)); match != nil { + keyID = match[1] + } + } + keyID, err = s.pick(keyID, nil, "What is the key ID of this APNs key?", "--key-id", + matchesPattern(apnsKeyID, "a key ID is 10 uppercase letters or digits")) + if err != nil { + return err + } + + for _, environment := range []struct { + sandbox bool + name string + existing *messagingProvider + }{ + {false, "APNs (" + bundleID + ")", production}, + {true, "APNs sandbox (" + bundleID + ")", sandbox}, + } { + body := map[string]any{ + "name": environment.name, + "authKey": key, + "authKeyId": keyID, + "teamId": teamID, + "bundleId": bundleID, + "sandbox": environment.sandbox, + "enabled": true, + } + if err := s.upsertProvider("apns", environment.existing, body); err != nil { + return err + } + } + + s.configureXcode(detected, bundleID) + output.Success(s.out, "APNs is set up for %s.", bundleID) + + return nil +} + func (s *pushSetup) fcm(options fcmOptions, detected androidApp) error { output.Log(s.out, "Setting up FCM ...") @@ -651,6 +927,27 @@ func expandHome(path string) string { return path } +func readApnsKey(path string) (string, error) { + contents, err := os.ReadFile(expandHome(path)) + if err != nil { + return "", err + } + block, _ := pem.Decode(contents) + if block == nil || block.Type != "PRIVATE KEY" { + return "", fmt.Errorf("%s is not an APNs auth key: expected a PEM 'PRIVATE KEY' block", path) + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("%s is not an APNs auth key: %w", path, err) + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok || ecKey.Curve != elliptic.P256() { + return "", fmt.Errorf("%s is not an APNs auth key: expected a P-256 elliptic curve key", path) + } + + return string(contents), nil +} + func readServiceAccount(path, projectID string) (map[string]any, error) { contents, err := os.ReadFile(expandHome(path)) if err != nil { @@ -706,6 +1003,18 @@ type messagingProvider struct { Options map[string]any `json:"options"` } +func (p messagingProvider) credential(name string) string { + value, _ := p.Credentials[name].(string) + + return value +} + +func (p messagingProvider) sandbox() bool { + value, _ := p.Options["sandbox"].(bool) + + return value +} + func (p messagingProvider) projectID() string { account, _ := p.Credentials["serviceAccountJSON"].(map[string]any) value, _ := account["project_id"].(string) @@ -758,6 +1067,51 @@ func (s *pushSetup) upsertProvider(kind string, existing *messagingProvider, bod return nil } +func (s *pushSetup) configureXcode(detected appleApp, bundleID string) { + targets := detected.targets(bundleID) + inXcode, entitlements, infoPlists := false, 0, 0 + for _, target := range targets { + if target.Project == "" { + continue + } + inXcode = true + for _, path := range target.Entitlements { + entitlements++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistString(contents, "aps-environment", "development") + }) + s.reportEdit(path, "aps-environment", changed, err) + } + for _, path := range target.InfoPlists { + infoPlists++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistArrayValue(contents, "UIBackgroundModes", "remote-notification") + }) + s.reportEdit(path, "the remote-notification background mode", changed, err) + } + } + + if inXcode && entitlements == 0 { + output.Hint(s.out, "In Xcode, open Signing & Capabilities and add Push Notifications, so the app gets an aps-environment entitlement.") + } + if inXcode && infoPlists == 0 { + 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.", + filepath.Base(detected.ExpoConfig)) + } +} + +func (s *pushSetup) reportEdit(path, what string, changed bool, err error) { + switch { + case err != nil: + output.Warn(s.out, "Could not add %s to %s: %s", what, path, err) + case changed: + output.Success(s.out, "Added %s to %s", what, path) + } +} + func (s *pushSetup) gradleHints(detected androidApp) { if detected.ExpoConfig != "" { if !detected.ExpoGoogleServices { @@ -783,3 +1137,106 @@ func (s *pushSetup) gradleHints(detected androidApp) { detected.Modules[0]) } } + +func editPlist(path string, edit func(string) string) (bool, error) { + contents, err := os.ReadFile(path) + if err != nil { + return false, err + } + original := string(contents) + if !strings.Contains(original, ""); index >= 0 && !strings.Contains(contents, "") { + indent, _ := lineIndent(contents, index) + contents = contents[:index] + "\n" + indent + "" + contents[index+len(""):] + } + end := strings.LastIndex(contents, "") + if end < 0 { + return contents, -1, "", "" + } + closing, lineStart := lineIndent(contents, end) + + unit := "\t" + if key := plistKeyLine.FindStringSubmatch(contents); key != nil && strings.HasPrefix(key[1], closing) && len(key[1]) > len(closing) { + unit = key[1][len(closing):] + } + + return contents, lineStart, closing + unit, unit +} + +var plistKeyLine = regexp.MustCompile(`(?m)^([ \t]*)`) + +func ensurePlistString(contents, key, value string) string { + if strings.Contains(contents, ""+key+"") { + return contents + } + contents, at, indent, _ := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + "" + key + "\n" + indent + "" + value + "\n" + contents[at:] +} + +func ensurePlistArrayValue(contents, key, value string) string { + marker := "" + key + "" + entry := "" + value + "" + if index := strings.Index(contents, marker); index >= 0 { + after := index + len(marker) + rest := contents[after:] + if empty := strings.Index(rest, ""); empty >= 0 && strings.TrimSpace(rest[:empty]) == "" { + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + at := after + empty + + return contents[:at] + "\n" + keyIndent + unit + entry + "\n" + keyIndent + "" + contents[at+len(""):] + } + open := strings.Index(rest, "") + closing := strings.Index(rest, "") + if open < 0 || closing < open || strings.TrimSpace(rest[:open]) != "" { + return contents + } + if strings.Contains(rest[open:closing], entry) { + return contents + } + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + _, lineStart := lineIndent(contents, after+closing) + if lineStart > 0 && contents[lineStart-1] != '\n' { + return contents + } + + return contents[:lineStart] + keyIndent + unit + entry + "\n" + contents[lineStart:] + } + + contents, at, indent, unit := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + marker + "\n" + indent + "\n" + indent + unit + entry + "\n" + indent + "\n" + contents[at:] +} diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 54ffd5e540..764df34cf5 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -2,6 +2,8 @@ package cmd import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -24,6 +26,111 @@ import ( "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" ) +const testPbxproj = `// !$*UTF8*$! +{ + objects = { + +/* Begin PBXNativeTarget section */ + A00000000000000000000001 /* MyApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */; + name = MyApp; + productType = "com.apple.product-type.application"; + }; + A00000000000000000000002 /* MyAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */; + name = MyAppTests; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A00000000000000000000003 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */; + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; + A00000000000000000000004 /* Admin */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */; + name = Admin; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin XCBuildConfiguration section */ + C00000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = MyApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "My App"; + }; + name = Debug; + }; + C00000000000000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = ABCDE12345; + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyAppTests; + }; + name = Debug; + }; + C00000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Widget/Widget.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = Widget/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.widget; + }; + name = Debug; + }; + C00000000000000000000004 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Admin/Admin.entitlements; + DEVELOPMENT_TEAM = FGHIJ67890; + INFOPLIST_FILE = Admin/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.admin; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000001 /* Debug */, + ); + }; + B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000002 /* Debug */, + ); + }; + B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000003 /* Debug */, + ); + }; + B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000004 /* Debug */, + ); + }; +/* End XCConfigurationList section */ + }; +} +` + +const myAppBundle = "org.reactjs.native.example.My-App" + func writeFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -34,6 +141,35 @@ func writeFile(t *testing.T, path, contents string) { } } +func TestDetectAppleReadsTheAppTarget(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "node_modules", "dep", "Dep.xcodeproj", "project.pbxproj"), + strings.ReplaceAll(testPbxproj, "ABCDE12345", "ZZZZZ99999")) + + app, err := detectApple(root) + if err != nil { + t.Fatal(err) + } + + if strings.Join(app.BundleIDs, ",") != "com.example.admin,"+myAppBundle { + t.Errorf("bundle IDs = %v", app.BundleIDs) + } + if strings.Join(app.teams(myAppBundle), ",") != "ABCDE12345" || strings.Join(app.teams("com.example.admin"), ",") != "FGHIJ67890" { + t.Errorf("teams = %v / %v", app.teams(myAppBundle), app.teams("com.example.admin")) + } + targets := app.targets(myAppBundle) + if len(targets) != 1 { + t.Fatalf("targets = %+v", app.Targets) + } + if strings.Join(targets[0].Entitlements, ",") != filepath.Join(root, "ios", "MyApp", "MyApp.entitlements") { + t.Errorf("entitlements = %v", targets[0].Entitlements) + } + if strings.Join(targets[0].InfoPlists, ",") != filepath.Join(root, "ios", "MyApp", "Info.plist") { + t.Errorf("Info.plist = %v", targets[0].InfoPlists) + } +} + func TestDetectAndroidReadsGradleAndGoogleServices(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "android", "app", "build.gradle.kts"), `plugins { @@ -73,17 +209,86 @@ android { func TestDetectReadsExpoConfig(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "app.json"), `{"expo": { + "ios": {"bundleIdentifier": "com.example.expo", "appleTeamId": "ABCDE12345"}, "android": {"package": "com.example.expo", "googleServicesFile": "./google-services.json"} }}`) writeFile(t, filepath.Join(root, "google-services.json"), `{"project_info": {"project_id": "expo-project"}, "client": []}`) + apple, _ := detectApple(root) android, _ := detectAndroid(root) - if android.ExpoConfig == "" || android.ApplicationIDs[0] != "com.example.expo" || strings.Join(android.projectIDs(), ",") != "expo-project" { + if !apple.found() || apple.BundleIDs[0] != "com.example.expo" || apple.TeamIDs[0] != "ABCDE12345" { + t.Errorf("apple = %+v", apple) + } + if !android.found() || android.ApplicationIDs[0] != "com.example.expo" || strings.Join(android.projectIDs(), ",") != "expo-project" { t.Errorf("android = %+v", android) } } +func TestEnsurePlistEdits(t *testing.T) { + empty := "\n\n\n\n" + got := ensurePlistString(empty, "aps-environment", "development") + want := "\n\n\n\taps-environment\n\tdevelopment\n\n\n" + if got != want { + t.Errorf("empty dict:\n%s", got) + } + if ensurePlistString(got, "aps-environment", "production") != got { + t.Error("an existing aps-environment was changed") + } + + info := "\n\n\tCFBundleName\n\tApp\n\tUIBackgroundModes\n\t\n\t\tfetch\n\t\n\n\n" + got = ensurePlistArrayValue(info, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, "\t\n\t\tfetch\n\t\tremote-notification\n\t") { + t.Errorf("existing array:\n%s", got) + } + if ensurePlistArrayValue(got, "UIBackgroundModes", "remote-notification") != got { + t.Error("remote-notification was added twice") + } + + expo := "\n \n CFBundleName\n App\n \n\n" + got = ensurePlistArrayValue(expo, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " App\n UIBackgroundModes\n \n remote-notification\n \n \n") { + t.Errorf("two-space plist:\n%s", got) + } + got = ensurePlistArrayValue(strings.Replace(got, "remote-notification", "fetch", 1), "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " fetch\n remote-notification\n ") { + t.Errorf("two-space array:\n%s", got) + } + + selfClosing := "\n\n\tUIBackgroundModes\n\t\n\n\n" + got = ensurePlistArrayValue(selfClosing, "UIBackgroundModes", "remote-notification") + if got != "\n\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n" { + t.Errorf("self-closing array:\n%s", got) + } + + bare := "\n\n\tNested\n\t\n\t\n\n\n" + got = ensurePlistArrayValue(bare, "UIBackgroundModes", "remote-notification") + if !strings.HasSuffix(got, "\t\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n") { + t.Errorf("new array:\n%s", got) + } +} + +func writeKey(t *testing.T, path string, key any) { + t.Helper() + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + writeFile(t, path, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))) +} + +func testApnsKey(t *testing.T, directory string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, "AuthKey_KEY1234567.p8") + writeKey(t, path, key) + + return path +} + func testServiceAccount(t *testing.T, directory, projectID string) string { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) @@ -104,6 +309,20 @@ func testServiceAccount(t *testing.T, directory, projectID string) string { return path } +func TestReadApnsKeyRejectsOtherKeys(t *testing.T) { + directory := t.TempDir() + if _, err := readApnsKey(testApnsKey(t, directory)); err != nil { + t.Errorf("P-256 key rejected: %s", err) + } + + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + rsaPath := filepath.Join(directory, "rsa.p8") + writeKey(t, rsaPath, rsaKey) + if _, err := readApnsKey(rsaPath); err == nil || !strings.Contains(err.Error(), "P-256") { + t.Errorf("RSA key accepted: %v", err) + } +} + func TestReadServiceAccountChecksTheProject(t *testing.T) { path := testServiceAccount(t, t.TempDir(), "demo-project") if _, err := readServiceAccount(path, "demo-project"); err != nil { @@ -116,14 +335,14 @@ func TestReadServiceAccountChecksTheProject(t *testing.T) { func TestWaitForKeyFileIgnoresEarlierFiles(t *testing.T) { directory := t.TempDir() - old := testServiceAccount(t, directory, "old-project") + old := testApnsKey(t, directory) past := time.Now().Add(-time.Hour) if err := os.Chtimes(old, past, past); err != nil { t.Fatal(err) } started := time.Now() - fresh := filepath.Join(directory, "new-project-firebase-adminsdk-abc.json") + fresh := filepath.Join(directory, "AuthKey_NEW1234567.p8") go func() { time.Sleep(50 * time.Millisecond) writeFile(t, filepath.Join(directory, "notes.txt"), "not a key") @@ -132,7 +351,7 @@ func TestWaitForKeyFileIgnoresEarlierFiles(t *testing.T) { }() path, err := waitForKeyFile([]string{t.TempDir(), directory}, started, func(path string) bool { - return strings.HasSuffix(path, ".json") + return apnsKeyFileName.MatchString(filepath.Base(path)) }, 5*time.Second, 10*time.Millisecond) if err != nil { t.Fatal(err) @@ -234,6 +453,76 @@ func newTestPushSetup(t *testing.T, server *httptest.Server, prompter prompt.Pro }, out } +func TestInitApnsCreatesBothEnvironmentsAndEditsXcode(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "ios", "MyApp", "MyApp.entitlements"), "\n\n\n") + writeFile(t, filepath.Join(root, "ios", "MyApp", "Info.plist"), "\n\n\n\n") + untouched := map[string]string{} + for _, path := range []string{"Admin/Admin.entitlements", "Admin/Info.plist", "Widget/Widget.entitlements", "Widget/Info.plist"} { + untouched[path] = "\n\n\n\n" + writeFile(t, filepath.Join(root, "ios", path), untouched[path]) + } + keyPath := testApnsKey(t, t.TempDir()) + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + detected, _ := detectApple(root) + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for index, sandbox := range []bool{false, true} { + provider := messaging.providers[index] + credentials := provider["credentials"].(map[string]any) + if credentials["bundleId"] != myAppBundle || credentials["teamId"] != "ABCDE12345" || + credentials["authKeyId"] != "KEY1234567" || provider["options"].(map[string]any)["sandbox"] != sandbox || + provider["enabled"] != true { + t.Errorf("provider %d = %v", index, provider) + } + } + + entitlements, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "MyApp.entitlements")) + if !strings.Contains(string(entitlements), "aps-environment\n\tdevelopment") { + t.Errorf("entitlements:\n%s", entitlements) + } + info, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "Info.plist")) + if !strings.Contains(string(info), "remote-notification") { + t.Errorf("Info.plist:\n%s", info) + } + for path, original := range untouched { + if contents, _ := os.ReadFile(filepath.Join(root, "ios", path)); string(contents) != original { + t.Errorf("%s was edited:\n%s", path, contents) + } + } + + messaging.requests = nil + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || strings.Join(messaging.requests, ",") != "GET /messaging/providers,PATCH /messaging/providers/apns/id0,PATCH /messaging/providers/apns/id1" { + t.Errorf("rerun requests = %v", messaging.requests) + } + + messaging.requests = nil + if err := setup.apns(apnsOptions{bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if strings.Join(messaging.requests, ",") != "GET /messaging/providers" { + t.Errorf("already set up requests = %v", messaging.requests) + } +} + func TestInitFcmRefusesAMismatchedApp(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) @@ -426,6 +715,32 @@ func (r recordingPrompter) Choice(question prompt.Choice) (string, error) { return r.Scripted.Choice(question) } +func TestInitApnsUsesAKeyInTheProjectFolder(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + testApnsKey(t, root) + writeFile(t, filepath.Join(root, "notes.p8"), "not a key") + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if !strings.Contains(out.String(), "Using AuthKey_KEY1234567.p8 from the project folder.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + func TestInitFcmUsesAKeyInTheProjectFolderBeforeAskingForAProject(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) @@ -471,13 +786,13 @@ func TestBrowserFlowFindsAKeySavedIntoTheProjectFolder(t *testing.T) { setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, root) go func() { time.Sleep(100 * time.Millisecond) - testServiceAccount(t, root, "saved-project") + testApnsKey(t, root) }() - if err := setup.fcm(fcmOptions{}, androidApp{}); err != nil { + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { t.Fatal(err) } - if len(messaging.providers) != 1 || messaging.providers[0]["name"] != "FCM (saved-project)" { + if len(messaging.providers) != 2 { t.Errorf("providers = %v", messaging.providers) } } @@ -490,16 +805,16 @@ func TestKeysInAGitRepositoryMustBeIgnored(t *testing.T) { if err := exec.Command("git", "-C", root, "init", "-q").Run(); err != nil { t.Skip("git init failed") } - key := testServiceAccount(t, root, "demo-project") + key := testApnsKey(t, root) out := &bytes.Buffer{} setup := &pushSetup{out: out, keyDirs: []string{root}} setup.checkKeyLocation(key) - if !strings.Contains(out.String(), "demo-project-firebase-adminsdk-abc.json is inside a git repository and not ignored") { + if !strings.Contains(out.String(), "AuthKey_KEY1234567.p8 is inside a git repository and not ignored") { t.Errorf("output:\n%s", out.String()) } - writeFile(t, filepath.Join(root, ".gitignore"), "*-firebase-adminsdk-*.json\n") + writeFile(t, filepath.Join(root, ".gitignore"), "*.p8\n") out.Reset() setup.checkKeyLocation(key) if out.Len() != 0 { diff --git a/templates/cli/internal/cmd/initpushdetect.go b/templates/cli/internal/cmd/initpushdetect.go index 6aad5ec23f..092aa9730e 100644 --- a/templates/cli/internal/cmd/initpushdetect.go +++ b/templates/cli/internal/cmd/initpushdetect.go @@ -12,6 +12,51 @@ import ( "strings" ) +type appleTarget struct { + Project string + BundleID string + TeamID string + Entitlements []string + InfoPlists []string +} + +type appleApp struct { + Projects []string + Targets []appleTarget + BundleIDs []string + TeamIDs []string + ExpoConfig string +} + +func (a appleApp) found() bool { + return len(a.Projects) > 0 || a.ExpoConfig != "" +} + +func (a appleApp) targets(bundleID string) []appleTarget { + var targets []appleTarget + for _, target := range a.Targets { + if target.BundleID == bundleID { + targets = append(targets, target) + } + } + + return targets +} + +func (a appleApp) teams(bundleID string) []string { + var teams []string + for _, target := range a.targets(bundleID) { + if target.TeamID != "" { + teams = appendUnique(teams, target.TeamID) + } + } + if len(teams) == 0 && len(a.TeamIDs) == 1 { + return a.TeamIDs + } + + return teams +} + type firebaseConfig struct { Path string ProjectID string @@ -30,6 +75,10 @@ type androidApp struct { HasMessaging bool } +func (a androidApp) found() bool { + return len(a.Modules) > 0 || a.ExpoConfig != "" +} + func (a androidApp) projectIDs() []string { var projects []string for _, config := range a.Firebase { @@ -81,8 +130,148 @@ func walkProject(root string, visit func(path string, entry fs.DirEntry) error) }) } +const applicationProductType = "com.apple.product-type.application" + +var ( + pbxObject = regexp.MustCompile(`(?ms)^\t\t([0-9A-Fa-f]{24})(?: /\*.*?\*/)? = \{\n(.*?)\n\t\t\};`) + pbxIsa = regexp.MustCompile(`(?m)^\t\t\tisa = (\w+);`) + pbxProductType = regexp.MustCompile(`(?m)^\t\t\tproductType = "?([^";]+)"?;`) + pbxConfigList = regexp.MustCompile(`(?m)^\t\t\tbuildConfigurationList = ([0-9A-Fa-f]{24})`) + pbxConfigs = regexp.MustCompile(`(?s)buildConfigurations = \((.*?)\);`) + pbxReference = regexp.MustCompile(`[0-9A-Fa-f]{24}`) + pbxBuildSettings = regexp.MustCompile(`(?s)buildSettings = \{(.*?)\n\t\t\t\};`) + pbxSetting = regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_]*) = (.*?);\s*$`) + appleTeamID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +func pbxValue(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + value = value[1 : len(value)-1] + } + + return value +} + +func rfc1034(value string) string { + var builder strings.Builder + for _, character := range value { + switch { + case character >= 'a' && character <= 'z', character >= 'A' && character <= 'Z', + character >= '0' && character <= '9', character == '-', character == '.': + builder.WriteRune(character) + default: + builder.WriteRune('-') + } + } + + return builder.String() +} + +func resolveBundleID(bundleID string, settings map[string]string) string { + name := settings["PRODUCT_NAME"] + if name != "" && !strings.Contains(name, "$(") { + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME:rfc1034identifier)", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME:rfc1034identifier}", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME)", name) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME}", name) + } + if strings.Contains(bundleID, "$") { + return "" + } + + return bundleID +} + +func projectPath(projectDir, value string) string { + value = strings.TrimPrefix(value, "$(SRCROOT)/") + value = strings.TrimPrefix(value, "${SRCROOT}/") + value = strings.TrimPrefix(value, "$(PROJECT_DIR)/") + if strings.Contains(value, "$") { + return "" + } + + return filepath.Join(projectDir, value) +} + +func pbxSettings(body string) map[string]string { + settings := map[string]string{} + block := pbxBuildSettings.FindStringSubmatch(body) + if block == nil { + return settings + } + for _, setting := range pbxSetting.FindAllStringSubmatch(block[1], -1) { + settings[setting[1]] = pbxValue(setting[2]) + } + + return settings +} + +func readXcodeProject(app *appleApp, xcodeproj string) { + contents, err := os.ReadFile(filepath.Join(xcodeproj, "project.pbxproj")) + if err != nil { + return + } + app.Projects = append(app.Projects, xcodeproj) + projectDir := filepath.Dir(xcodeproj) + + objects := map[string]string{} + var applications []string + for _, object := range pbxObject.FindAllStringSubmatch(string(contents), -1) { + objects[object[1]] = object[2] + isa := pbxIsa.FindStringSubmatch(object[2]) + productType := pbxProductType.FindStringSubmatch(object[2]) + if isa != nil && isa[1] == "PBXNativeTarget" && productType != nil && productType[1] == applicationProductType { + applications = append(applications, object[2]) + } + } + + for _, application := range applications { + list := pbxConfigList.FindStringSubmatch(application) + if list == nil { + continue + } + configs := pbxConfigs.FindStringSubmatch(objects[list[1]]) + if configs == nil { + continue + } + + byBundle := map[string]*appleTarget{} + var order []string + for _, reference := range pbxReference.FindAllString(configs[1], -1) { + settings := pbxSettings(objects[reference]) + bundleID := resolveBundleID(settings["PRODUCT_BUNDLE_IDENTIFIER"], settings) + if bundleID == "" { + continue + } + target, ok := byBundle[bundleID] + if !ok { + target = &appleTarget{Project: xcodeproj, BundleID: bundleID} + byBundle[bundleID] = target + order = append(order, bundleID) + } + if team := settings["DEVELOPMENT_TEAM"]; appleTeamID.MatchString(team) && target.TeamID == "" { + target.TeamID = team + } + if path := projectPath(projectDir, settings["CODE_SIGN_ENTITLEMENTS"]); settings["CODE_SIGN_ENTITLEMENTS"] != "" && path != "" { + target.Entitlements = appendUnique(target.Entitlements, path) + } + if path := projectPath(projectDir, settings["INFOPLIST_FILE"]); settings["INFOPLIST_FILE"] != "" && path != "" { + target.InfoPlists = appendUnique(target.InfoPlists, path) + } + } + for _, bundleID := range order { + app.Targets = append(app.Targets, *byBundle[bundleID]) + } + } +} + type expoConfig struct { Expo struct { + IOS struct { + BundleIdentifier string `json:"bundleIdentifier"` + AppleTeamID string `json:"appleTeamId"` + } `json:"ios"` Android struct { Package string `json:"package"` GoogleServicesFile string `json:"googleServicesFile"` @@ -106,6 +295,54 @@ func readExpoConfig(root string) (expoConfig, string) { return expoConfig{}, "" } +func detectApple(root string) (appleApp, error) { + app := appleApp{} + + err := walkProject(root, func(path string, entry fs.DirEntry) error { + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".xcodeproj") { + readXcodeProject(&app, path) + + return filepath.SkipDir + } + + return nil + }) + if err != nil { + return app, err + } + + config, path := readExpoConfig(root) + if bundleID := config.Expo.IOS.BundleIdentifier; bundleID != "" { + app.ExpoConfig = path + team := "" + if appleTeamID.MatchString(config.Expo.IOS.AppleTeamID) { + team = config.Expo.IOS.AppleTeamID + } + matched := false + for index := range app.Targets { + if app.Targets[index].BundleID == bundleID { + matched = true + if app.Targets[index].TeamID == "" { + app.Targets[index].TeamID = team + } + } + } + if !matched { + app.Targets = append(app.Targets, appleTarget{BundleID: bundleID, TeamID: team}) + } + } + + for _, target := range app.Targets { + app.BundleIDs = appendUnique(app.BundleIDs, target.BundleID) + if target.TeamID != "" { + app.TeamIDs = appendUnique(app.TeamIDs, target.TeamID) + } + } + sort.Strings(app.BundleIDs) + + return app, nil +} + var ( gradleApplicationPlugin = regexp.MustCompile(`com\.android\.application`) gradleApplicationID = regexp.MustCompile(`\bapplicationId\s*(?:=\s*)?["']([^"']+)["']`) From 4400a9f984bc9b5f9430907a04740889b3e17859 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 5 Oct 2026 16:21:38 +0530 Subject: [PATCH 02/29] test(cli): check that rotating the APNs key updates both providers in place --- templates/cli/internal/cmd/initpush_test.go | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 764df34cf5..237af07437 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -506,12 +506,29 @@ func TestInitApnsCreatesBothEnvironmentsAndEditsXcode(t *testing.T) { } } - messaging.requests = nil - if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: myAppBundle}, detected); err != nil { + previous := map[string]any{} + for _, provider := range messaging.providers { + previous[provider["$id"].(string)] = provider["credentials"].(map[string]any)["authKey"] + } + rotated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { t.Fatal(err) } - if len(messaging.providers) != 2 || strings.Join(messaging.requests, ",") != "GET /messaging/providers,PATCH /messaging/providers/apns/id0,PATCH /messaging/providers/apns/id1" { - t.Errorf("rerun requests = %v", messaging.requests) + rotatedPath := filepath.Join(t.TempDir(), "AuthKey_ROTATE1234.p8") + writeKey(t, rotatedPath, rotated) + if err := setup.apns(apnsOptions{keyPath: rotatedPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers after rotation = %v", messaging.providers) + } + for _, provider := range messaging.providers { + id := provider["$id"].(string) + credentials := provider["credentials"].(map[string]any) + old, kept := previous[id] + if !kept || credentials["authKeyId"] != "ROTATE1234" || credentials["authKey"] == old { + t.Errorf("provider %s after rotation = %v", id, credentials) + } } messaging.requests = nil From 80fbad057ce1e9ccf557cf2e3f851f84b93a682a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:44:55 +0530 Subject: [PATCH 03/29] feat(cli): add the apns package for APNs key setups Apple has no public API for creating APNs keys, and the tools that do it (spaceship, @expo/apple-utils, Appwrite's own) all drive the Developer portal's private one, which Apple can change at any time. Each becomes an Adapter behind one contract: CreateKey checks the request, runs the adapter and checks the key it returns (key ID, team, .p8 contents), so every setup behaves the same to the caller. A Registry finds them by name. The package imports nothing from the CLI: prompts, logging, credentials and the session folder come in through the Request, so it can move to its own module unchanged. apnstest has a scripted adapter for tests. --- src/SDK/Language/CLI.php | 15 ++ templates/cli/internal/apns/apns.go | 193 ++++++++++++++++++ templates/cli/internal/apns/apns_test.go | 115 +++++++++++ .../cli/internal/apns/apnstest/apnstest.go | 62 ++++++ 4 files changed, 385 insertions(+) create mode 100644 templates/cli/internal/apns/apns.go create mode 100644 templates/cli/internal/apns/apns_test.go create mode 100644 templates/cli/internal/apns/apnstest/apnstest.go diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index f57dc89c56..946e66a73e 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1146,6 +1146,21 @@ public function getFiles(): array 'destination' => 'internal/cmd/initpush_test.go', 'template' => 'cli/internal/cmd/initpush_test.go', ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/apns.go', + 'template' => 'cli/internal/apns/apns.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apns_test.go', + 'template' => 'cli/internal/apns/apns_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apnstest/apnstest.go', + 'template' => 'cli/internal/apns/apnstest/apnstest.go', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go new file mode 100644 index 0000000000..e2db01f368 --- /dev/null +++ b/templates/cli/internal/apns/apns.go @@ -0,0 +1,193 @@ +// Package apns creates Apple Push Notification service (APNs) auth keys. +// +// Apple has no public API for creating APNs keys. The tools that automate it, +// fastlane's spaceship and @expo/apple-utils, sign in to the Apple Developer +// portal as a person would and call the portal's private API, and so does +// Appwrite's own implementation. Apple can change that API without notice, so +// each implementation is an Adapter, and which one runs is a setting rather +// than a release: when Apple breaks one, people switch to another. +// +// The package imports nothing from the CLI. Prompts and logging come in +// through the Request, so it can move to its own module and repository +// unchanged. +package apns + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +// Environment is the APNs environment a key works in. +type Environment string + +// The environments Apple offers when creating a key. +const ( + EnvironmentAll Environment = "all" + EnvironmentProduction Environment = "production" + EnvironmentSandbox Environment = "sandbox" +) + +// ParseEnvironment reads an environment name. Empty means all. +func ParseEnvironment(value string) (Environment, error) { + switch environment := Environment(strings.ToLower(strings.TrimSpace(value))); environment { + case "", EnvironmentAll: + return EnvironmentAll, nil + case EnvironmentProduction, EnvironmentSandbox: + return environment, nil + default: + return "", fmt.Errorf("unknown APNs environment %q: use production, sandbox or all", value) + } +} + +// Key is a newly created APNs key. Apple lets its contents be downloaded once. +type Key struct { + KeyID string + TeamID string + P8 string +} + +// ExistingKey is a key already on the team. +type ExistingKey struct { + ID string + Name string + CanRevoke bool +} + +// MaxKeysError means the team already has as many APNs keys as Apple allows. +// Keys lists the team's keys when the adapter could read them. +type MaxKeysError struct { + Keys []ExistingKey +} + +func (e *MaxKeysError) Error() string { + return "the Apple team already has the maximum number of APNs keys" +} + +var ( + // ErrUnavailable means the adapter cannot run here, for example because + // the runtime it needs is missing. Nothing was sent to Apple. + 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") +) + +// Asker asks the person at the terminal. +type Asker interface { + // Ask reads one line. secret hides what is typed. + Ask(question string, secret bool) (string, error) + // Choose returns the index of the chosen option. + Choose(question string, options []string) (int, error) +} + +// Request describes the key to create. +type Request struct { + // TeamID is the Apple Developer team that owns the key. Empty lets the + // adapter use the account's only team, or ask which one. + TeamID string + Name string + Environment Environment + // AppleID and Password answer the sign-in questions when set. + AppleID string + Password string + // SessionDir is where an adapter may keep a signed-in session between + // runs, readable by the user only. + SessionDir string + Asker Asker + // Log prints a line for the person running the command. + Log func(format string, args ...any) +} + +// Adapter creates APNs keys one way. +type Adapter interface { + // Name is how people pick the adapter, for example "appwrite". + Name() string + // CreateKey signs in, creates the key and downloads it. It returns + // ErrUnavailable when it cannot run here, ErrInvalidCredentials when + // Apple refuses the sign-in, and a *MaxKeysError when the team has no + // free key slot. + CreateKey(ctx context.Context, request Request) (Key, error) +} + +var ( + keyIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) + teamIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +// CreateKey checks the request, runs the adapter, and checks what it returned, +// so every adapter is held to the same contract. +func CreateKey(ctx context.Context, adapter Adapter, request Request) (Key, error) { + environment, err := ParseEnvironment(string(request.Environment)) + if err != nil { + return Key{}, err + } + request.Environment = environment + if strings.TrimSpace(request.Name) == "" { + return Key{}, errors.New("an APNs key needs a name") + } + if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) { + return Key{}, 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) {} + } + + key, err := adapter.CreateKey(ctx, request) + if err != nil { + return Key{}, err + } + switch { + case !keyIDPattern.MatchString(key.KeyID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid key ID %q", adapter.Name(), key.KeyID) + case !teamIDPattern.MatchString(key.TeamID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), key.TeamID) + case request.TeamID != "" && key.TeamID != request.TeamID: + return Key{}, fmt.Errorf("the %s APNs setup created key %s on team %s, not %s", adapter.Name(), key.KeyID, key.TeamID, request.TeamID) + case !strings.Contains(key.P8, "PRIVATE KEY"): + return Key{}, fmt.Errorf("the %s APNs setup created key %s but returned no .p8 contents", adapter.Name(), key.KeyID) + } + + return key, nil +} + +// Registry holds the adapters a build offers, by name. +type Registry struct { + adapters map[string]Adapter +} + +// NewRegistry returns a registry holding adapters. +func NewRegistry(adapters ...Adapter) *Registry { + registry := &Registry{adapters: map[string]Adapter{}} + for _, adapter := range adapters { + registry.adapters[adapter.Name()] = adapter + } + + return registry +} + +// Names lists the registered adapters in order. +func (r *Registry) Names() []string { + names := make([]string, 0, len(r.adapters)) + for name := range r.adapters { + names = append(names, name) + } + sort.Strings(names) + + return names +} + +// Get returns the adapter called name. +func (r *Registry) Get(name string) (Adapter, error) { + name = strings.ToLower(strings.TrimSpace(name)) + if adapter, ok := r.adapters[name]; ok { + return adapter, nil + } + if len(r.adapters) == 0 { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", name) + } + + return nil, fmt.Errorf("unknown APNs key setup %q: use one of %s", name, strings.Join(r.Names(), ", ")) +} diff --git a/templates/cli/internal/apns/apns_test.go b/templates/cli/internal/apns/apns_test.go new file mode 100644 index 0000000000..dc334abeb9 --- /dev/null +++ b/templates/cli/internal/apns/apns_test.go @@ -0,0 +1,115 @@ +package apns_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" +) + +func TestParseEnvironment(t *testing.T) { + for value, want := range map[string]apns.Environment{ + "": apns.EnvironmentAll, + "all": apns.EnvironmentAll, + "Production": apns.EnvironmentProduction, + " sandbox ": apns.EnvironmentSandbox, + } { + if got, err := apns.ParseEnvironment(value); err != nil || got != want { + t.Errorf("ParseEnvironment(%q) = %q, %v", value, got, err) + } + } + if _, err := apns.ParseEnvironment("staging"); err == nil { + t.Error("accepted staging") + } +} + +func TestCreateKeyPassesTheRequestThrough(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234", TeamID: "TEAM123456"} + key, err := apns.CreateKey(context.Background(), adapter, apns.Request{Name: "Appwrite Push", Environment: "Sandbox"}) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "TEAM123456" { + t.Errorf("key = %+v", key) + } + if len(adapter.Requests) != 1 || adapter.Requests[0].Environment != apns.EnvironmentSandbox || adapter.Requests[0].Log == nil { + t.Errorf("requests = %+v", adapter.Requests) + } +} + +func TestCreateKeyChecksTheRequest(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234"} + for _, request := range []apns.Request{ + {Name: "Appwrite Push", Environment: "staging"}, + {Name: " "}, + {Name: "Appwrite Push", TeamID: "team"}, + } { + if _, err := apns.CreateKey(context.Background(), adapter, request); err == nil { + t.Errorf("accepted %+v", request) + } + } + if len(adapter.Requests) != 0 { + t.Errorf("ran the adapter for a bad request: %+v", adapter.Requests) + } +} + +func TestCreateKeyHoldsAdaptersToTheContract(t *testing.T) { + for _, test := range []struct { + name string + adapter apns.Adapter + want string + }{ + {"key ID", &apnstest.Adapter{KeyID: "short", TeamID: "TEAM123456"}, `invalid key ID "short"`}, + {"team ID", &apnstest.Adapter{KeyID: "NEWKEY1234"}, `invalid team ID ""`}, + {"other team", &otherTeam{}, "on team OTHER12345, not TEAM123456"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := apns.CreateKey(context.Background(), test.adapter, apns.Request{Name: "Appwrite Push", TeamID: teamFor(test.name)}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("err = %v", err) + } + }) + } + + failing := &apnstest.Adapter{Err: apns.ErrInvalidCredentials} + if _, err := apns.CreateKey(context.Background(), failing, apns.Request{Name: "Appwrite Push"}); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("err = %v", err) + } +} + +func teamFor(test string) string { + if test == "other team" { + return "TEAM123456" + } + + return "" +} + +type otherTeam struct{} + +func (otherTeam) Name() string { return "other" } + +func (otherTeam) CreateKey(context.Context, apns.Request) (apns.Key, error) { + return apns.Key{KeyID: "NEWKEY1234", TeamID: "OTHER12345", P8: apnstest.P8()}, nil +} + +func TestRegistry(t *testing.T) { + empty := apns.NewRegistry() + if _, err := empty.Get("appwrite"); err == nil || !strings.Contains(err.Error(), "this build has none") { + t.Errorf("empty registry err = %v", err) + } + + registry := apns.NewRegistry(&apnstest.Adapter{AdapterName: "expo"}, &apnstest.Adapter{AdapterName: "appwrite"}) + if names := strings.Join(registry.Names(), ","); names != "appwrite,expo" { + t.Errorf("names = %s", names) + } + if adapter, err := registry.Get(" Expo "); err != nil || adapter.Name() != "expo" { + t.Errorf("Get(expo) = %v, %v", adapter, err) + } + if _, err := registry.Get("fastlane"); err == nil || err.Error() != `unknown APNs key setup "fastlane": use one of appwrite, expo` { + t.Errorf("err = %v", err) + } +} diff --git a/templates/cli/internal/apns/apnstest/apnstest.go b/templates/cli/internal/apns/apnstest/apnstest.go new file mode 100644 index 0000000000..5f92d12ea9 --- /dev/null +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -0,0 +1,62 @@ +// Package apnstest has a scripted APNs adapter for tests. +package apnstest + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter is a fake that records each request and returns a fresh P-256 key, +// or Err when set. +type Adapter struct { + AdapterName string + KeyID string + // TeamID is used when the request names no team, as when an account + // with one team signs in. + TeamID string + Err error + Requests []apns.Request +} + +// Name returns AdapterName, or "fake". +func (a *Adapter) Name() string { + if a.AdapterName != "" { + return a.AdapterName + } + + return "fake" +} + +// CreateKey records the request and returns a key or Err. +func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { + a.Requests = append(a.Requests, request) + if a.Err != nil { + return apns.Key{}, a.Err + } + team := request.TeamID + if team == "" { + team = a.TeamID + } + + return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8()}, nil +} + +// P8 returns a new APNs-shaped private key in PEM form. +func P8() string { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + panic(err) + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) +} From 0101320ac085214f53ed90e965b261f2df5b25e2 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:44:55 +0530 Subject: [PATCH 04/29] feat(cli): init apns --create-key through a chosen APNs key setup --create-key creates the key with the setup picked by --provider, then APPWRITE_APNS_SETUP, then appwrite, so a setup Apple breaks can be swapped without a release. APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD answer the sign-in questions, and the CLI names the variable it used. --environment (production, sandbox or all) picks the providers to set up and the environment of a created key. Without a known team, the sign-in chooses it. The new .p8 is saved before the providers are written, since Apple allows one download, and a failed --create-key stops rather than falling back to a key already in the folder. No setup is registered yet: each comes in its own change, and until then the sign-in option is not offered. --- templates/cli/internal/cmd/initpush.go | 301 +++++++++++++-- templates/cli/internal/cmd/initpush_test.go | 400 ++++++++++++++++++++ 2 files changed, 676 insertions(+), 25 deletions(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index f2cb9abd90..85a907e01d 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -18,11 +18,14 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "time" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/output" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" "github.com/spf13/cobra" @@ -30,6 +33,7 @@ import ( const ( apnsKeyPage = "https://developer.apple.com/account/resources/authkeys/add" + apnsKeysListPage = "https://developer.apple.com/account/resources/authkeys/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" @@ -44,10 +48,13 @@ var ( ) type apnsOptions struct { - keyPath string - keyID string - teamID string - bundleID string + keyPath string + keyID string + teamID string + bundleID string + createKey bool + environment string + setup string } type fcmOptions struct { @@ -69,6 +76,12 @@ type pushSetup struct { googleHosts map[string]string googlePoll time.Duration googleTimeout time.Duration + ctx context.Context + getenv func(string) string + // apnsAdapters are the ways --create-key can create an APNs key, and + // apnsSessions is where they keep a signed-in session. + apnsAdapters *apns.Registry + apnsSessions string } func newPushSetup(command *cobra.Command) (*pushSetup, error) { @@ -89,6 +102,11 @@ func newPushSetup(command *cobra.Command) (*pushSetup, error) { } } + sessions := "" + if prefs, err := config.GlobalPath(app.ExecutableName); err == nil { + sessions = filepath.Join(filepath.Dir(prefs), "apns") + } + return &pushSetup{ api: context.api, prompter: context.prompter, @@ -101,6 +119,10 @@ func newPushSetup(command *cobra.Command) (*pushSetup, error) { console: newConsoleForPush(), googlePoll: 2 * time.Second, googleTimeout: googleSignInTimeout, + ctx: command.Context(), + getenv: os.Getenv, + apnsAdapters: apnsAdapters(), + apnsSessions: sessions, }, nil } @@ -128,6 +150,10 @@ func newInitApnsCommand() *cobra.Command { flags.StringVar(&options.keyID, "key-id", "", "APNs key ID") flags.StringVar(&options.teamID, "team-id", "", "Apple Developer team ID") flags.StringVar(&options.bundleID, "bundle-id", "", "iOS app bundle ID") + flags.BoolVar(&options.createKey, "create-key", false, "Create the APNs key by signing in with your Apple ID (experimental)") + 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) + command.MarkFlagsMutuallyExclusive("create-key", "key-path") return command } @@ -343,6 +369,13 @@ func matchesPattern(pattern *regexp.Regexp, description string) func(string) err func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { output.Log(s.out, "Setting up APNs ...") + environment, err := apnsEnvironment(options.environment) + if 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")) if err != nil { @@ -359,8 +392,11 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { sandbox := findProvider(existing, func(provider messagingProvider) bool { return provider.credential("bundleId") == bundleID && provider.sandbox() }) - if production != nil && sandbox != nil && production.Enabled && sandbox.Enabled && options.keyPath == "" && !app.Flags().Force { - output.Success(s.out, "APNs is already set up for %s.", bundleID) + setUp := func(provider *messagingProvider, wanted bool) bool { + return !wanted || (provider != nil && provider.Enabled) + } + if setUp(production, wantProduction) && setUp(sandbox, wantSandbox) && options.keyPath == "" && !app.Flags().Force { + 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) @@ -373,25 +409,35 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { teams = []string{provider.credential("teamId")} } } - teamID, err := s.pick(options.teamID, teams, - "What is your Apple Developer team ID?", "--team-id", - matchesPattern(appleTeamID, "a team ID is 10 uppercase letters or digits")) - if err != nil { - return err + pickTeam := func() (string, error) { + return s.pick(options.teamID, teams, + "What is your Apple Developer team ID?", "--team-id", + matchesPattern(appleTeamID, "a team ID is 10 uppercase letters or digits")) + } + // Without a known team, signing in with the Apple ID chooses it, so the + // question waits until the key has come from somewhere else. + teamID := "" + if options.teamID != "" || len(teams) > 0 || options.keyPath != "" { + if teamID, err = pickTeam(); err != nil { + return err + } } keyPath := options.keyPath if keyPath == "" { - keyPath, _, err = s.acquireKey("APNs auth key (.p8)", "", "Create one in the Apple Developer portal", "--key-path", apnsKeyPage, - "In the Apple Developer portal, create a key with Apple Push Notifications service (APNs) enabled and download it.", - func(path string) bool { - _, err := readApnsKey(path) - - return strings.HasSuffix(path, ".p8") && err == nil - }) + var keyTeam string + keyPath, keyTeam, err = s.apnsKey(teamID, options, environment) if err != nil { return err } + if teamID == "" { + teamID = keyTeam + } + } + if teamID == "" { + if teamID, err = pickTeam(); err != nil { + return err + } } key, err := readApnsKey(keyPath) @@ -414,34 +460,239 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { return err } - for _, environment := range []struct { + for _, target := range []struct { sandbox bool + wanted bool name string existing *messagingProvider }{ - {false, "APNs (" + bundleID + ")", production}, - {true, "APNs sandbox (" + bundleID + ")", sandbox}, + {false, wantProduction, "APNs (" + bundleID + ")", production}, + {true, wantSandbox, "APNs sandbox (" + bundleID + ")", sandbox}, } { + if !target.wanted { + continue + } body := map[string]any{ - "name": environment.name, + "name": target.name, "authKey": key, "authKeyId": keyID, "teamId": teamID, "bundleId": bundleID, - "sandbox": environment.sandbox, + "sandbox": target.sandbox, "enabled": true, } - if err := s.upsertProvider("apns", environment.existing, body); err != nil { + if err := s.upsertProvider("apns", target.existing, body); err != nil { return err } } s.configureXcode(detected, bundleID) - output.Success(s.out, "APNs is set up for %s.", bundleID) + output.Success(s.out, "APNs%s is set up for %s.", environmentLabel(environment), bundleID) return nil } +// apnsKey finds or creates the APNs key and returns its path. With +// --create-key, signing in with the Apple ID is the only way, and a failure +// stops the command. Otherwise it is offered alongside the browser flow, and +// when it fails the other ways of providing the key remain. +func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, error) { + create := options.createKey + explicit := create + automatic := "" + if s.apnsAdapters != nil && len(s.apnsAdapters.Names()) > 0 { + automatic = "Sign in with your Apple ID and create one (experimental)" + } + for { + if !create { + path, _, err := s.acquireKey("APNs auth key (.p8)", automatic, "Create one in the Apple Developer portal", "--key-path", apnsKeyPage, + "In the Apple Developer portal, create a key with Apple Push Notifications service (APNs) enabled and download it.", + func(path string) bool { + _, err := readApnsKey(path) + + return strings.HasSuffix(path, ".p8") && err == nil + }) + if !errors.Is(err, errAutomaticKey) { + return path, "", err + } + } + create = false + + path, keyTeam, err := s.createApnsKey(teamID, options.setup, environment) + var maxKeys *apns.MaxKeysError + if errors.As(err, &maxKeys) { + output.Warn(s.out, "Apple allows two APNs keys per team, and this team has no free slot. Keys on the team:") + for _, key := range maxKeys.Keys { + output.Log(s.out, " %s (%s)", key.Name, key.ID) + } + output.Hint(s.out, "One key works for every app on the team. Reuse a .p8 you saved earlier, or revoke a key you no longer use at %s.", apnsKeysListPage) + + return "", "", errors.New("no APNs key can be created on this team. Pass --key-path with an existing .p8 instead") + } + if err == nil { + return path, keyTeam, nil + } + // --create-key asked for a new key: falling back to a .p8 already in + // the project folder would hide that none was created. + if explicit { + return "", "", fmt.Errorf("could not create the APNs key with your Apple ID: %w", err) + } + output.Warn(s.out, "Could not create the APNs key with your Apple ID: %s.", err) + output.Log(s.out, "Provide the key another way instead.") + automatic = "" + } +} + +const ( + // apnsSetupVariable picks the APNs key setup when --provider is not + // passed, so a broken one can be swapped without a release. + apnsSetupVariable = "APPWRITE_APNS_SETUP" + defaultApnsSetup = "appwrite" + // The Apple ID and password, when set, answer the sign-in questions. + appleIDVariable = "APPWRITE_APPLE_ID" + applePasswordVariable = "APPWRITE_APPLE_PASSWORD" +) + +// apnsAdapters lists the APNs key setups this build offers. Each lives in its +// own package under internal/apns and is added here. +func apnsAdapters() *apns.Registry { + return apns.NewRegistry() +} + +// apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then +// appwrite. +func (s *pushSetup) apnsAdapter(setup string) (apns.Adapter, error) { + if setup == "" { + setup = s.env(apnsSetupVariable) + } + if setup == "" { + setup = defaultApnsSetup + } + if s.apnsAdapters == nil { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", setup) + } + + return s.apnsAdapters.Get(setup) +} + +func (s *pushSetup) env(name string) string { + if s.getenv == nil { + return "" + } + + return s.getenv(name) +} + +func (s *pushSetup) context() context.Context { + if s.ctx == nil { + return context.Background() + } + + return s.ctx +} + +// apnsEnvironment reads --environment. Empty means both, for init push. +func apnsEnvironment(value string) (apns.Environment, error) { + environment, err := apns.ParseEnvironment(value) + if err != nil { + return "", fmt.Errorf("--environment must be production, sandbox or all, not %q", value) + } + + return environment, nil +} + +// environmentLabel names a single environment in messages, and nothing for +// both. +func environmentLabel(environment apns.Environment) string { + if environment == apns.EnvironmentAll { + return "" + } + + return " (" + string(environment) + ")" +} + +// apnsAsker asks an APNs setup's questions through the CLI's prompts, so they +// look like every other question and fail with a clear message without a +// terminal. +type apnsAsker struct { + prompter prompt.Prompter +} + +func (a apnsAsker) Ask(question string, secret bool) (string, error) { + return a.prompter.Text(prompt.Text{Message: question, Secret: secret, Flag: "--key-path", Validate: requiredValue("an answer")}) +} + +func (a apnsAsker) Choose(question string, options []string) (int, error) { + choices := make([]prompt.Option, len(options)) + for index, label := range options { + choices[index] = prompt.Option{Label: label, Value: strconv.Itoa(index)} + } + value, err := a.prompter.Choice(prompt.Choice{Message: question, Options: choices, Flag: "--key-path"}) + if err != nil { + return 0, err + } + + 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, setup string, environment apns.Environment) (string, string, error) { + adapter, err := s.apnsAdapter(setup) + if err != nil { + return "", "", err + } + request := apns.Request{ + TeamID: teamID, + Name: "Appwrite Push " + time.Now().Format("20060102150405"), + Environment: environment, + AppleID: s.env(appleIDVariable), + Password: s.env(applePasswordVariable), + Asker: apnsAsker{s.prompter}, + Log: func(format string, args ...any) { + output.Log(s.out, format, args...) + }, + } + if s.apnsSessions != "" { + request.SessionDir = filepath.Join(s.apnsSessions, adapter.Name()) + } + output.Log(s.out, "Signing in to the Apple Developer portal (%s).", adapter.Name()) + if request.AppleID != "" { + output.Log(s.out, "Using the Apple ID from %s.", appleIDVariable) + } + if request.Password != "" { + output.Log(s.out, "Using the Apple ID password from %s.", applePasswordVariable) + } + key, err := apns.CreateKey(s.context(), adapter, request) + if err != nil { + return "", "", err + } + + directory := s.root + if len(s.keyDirs) > 0 { + directory = s.keyDirs[0] + } + path := filepath.Join(directory, "AuthKey_"+key.KeyID+".p8") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + _, err = file.WriteString(key.P8) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + + output.Success(s.out, "Created APNs key %s on team %s and saved it to %s.", key.KeyID, key.TeamID, s.display(path)) + output.Hint(s.out, "Apple lets you download this key only once. Keep the file somewhere safe and out of version control.") + s.checkKeyLocation(path) + + return path, key.TeamID, nil +} + func (s *pushSetup) fcm(options fcmOptions, detected androidApp) error { output.Log(s.out, "Setting up FCM ...") diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 0c6246b481..47d68c80b8 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -22,6 +22,8 @@ import ( "testing" "time" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" ) @@ -1212,3 +1214,401 @@ func TestInitFcmExplainsAServerWithoutTheFirebaseManagementAPI(t *testing.T) { t.Errorf("providers = %v", messaging.providers) } } + +// useAdapter makes adapter the only APNs key setup, under the default name, +// as an account with one team (APPLE12345) would see it. +func useAdapter(setup *pushSetup, adapter *apnstest.Adapter) *apnstest.Adapter { + adapter.AdapterName = defaultApnsSetup + if adapter.TeamID == "" { + adapter.TeamID = "APPLE12345" + } + setup.apnsAdapters = apns.NewRegistry(adapter) + + return adapter +} +func TestInitApnsCreatesTheKeyWithTheAppleID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "ABCDE12345" || !strings.HasPrefix(appleKey.Requests[0].Name, "Appwrite Push ") { + t.Errorf("helper calls = %v", appleKey.Requests) + } + saved := filepath.Join(root, "AuthKey_NEWKEY1234.p8") + info, err := os.Stat(saved) + if err != nil { + t.Fatalf("key was not saved: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("key mode = %v", info.Mode().Perm()) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for _, provider := range messaging.providers { + credentials := provider["credentials"].(map[string]any) + if credentials["authKeyId"] != "NEWKEY1234" || credentials["teamId"] != "ABCDE12345" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Created APNs key NEWKEY1234 on team ABCDE12345 and saved it to AuthKey_NEWKEY1234.p8.") { + t.Errorf("output:\n%s", out.String()) + } + + // A second run finds both providers and does not spend another key slot. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 { + t.Errorf("created another key on rerun: %v", appleKey.Requests) + } +} + +func TestInitApnsOffersTheAppleIDWhenNoKeyIsFound(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{ + "How would you like to provide the APNs auth key (.p8)?": "automatic", + }} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || len(messaging.providers) != 2 { + t.Errorf("calls = %v, providers = %v", appleKey.Requests, messaging.providers) + } +} + +func TestInitApnsStopsWhenCreateKeyFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + // A key already in the project folder must not stand in for the one + // --create-key was asked to create. + testApnsKey(t, root) + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || err.Error() != "could not create the APNs key with your Apple ID: could not reach Apple" { + t.Fatalf("err = %v", err) + } + if len(scripted.Asked) != 0 || len(messaging.providers) != 0 { + t.Errorf("asked %v, providers %v", scripted.Asked, messaging.providers) + } +} + +// choiceSequence answers the same choice differently each time it is asked. +type choiceSequence struct { + *prompt.Scripted + answers []string +} + +func (c *choiceSequence) Choice(question prompt.Choice) (string, error) { + c.Scripted.Asked = append(c.Scripted.Asked, question.Message) + answer := c.answers[0] + c.answers = c.answers[1:] + + return answer, nil +} + +func TestInitApnsFallsBackWhenTheChosenAppleSignInFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &choiceSequence{ + Scripted: &prompt.Scripted{Texts: map[string]string{"Path to the APNs auth key (.p8)": existing}}, + answers: []string{"automatic", "file"}, + } + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Could not create the APNs key with your Apple ID: could not reach Apple.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsStopsWhenTheTeamHasNoFreeKeySlot(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := &apnstest.Adapter{Err: &apns.MaxKeysError{Keys: []apns.ExistingKey{ + {ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, + {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}, + }}} + useAdapter(setup, appleKey) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "--key-path") { + t.Fatalf("err = %v", err) + } + for _, line := range []string{"Firebase (OLDKEY1234)", "Expo (OLDKEY5678)", apnsKeysListPage} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } + if entries, _ := os.ReadDir(root); len(entries) != 0 { + t.Errorf("wrote %v", entries) + } +} + +func TestInitApnsTakesTheTeamFromTheAppleSignIn(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "" { + t.Errorf("helper calls = %v", appleKey.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["teamId"] != "APPLE12345" { + t.Errorf("provider = %v", provider) + } + } +} + +func TestInitApnsAsksForTheTeamAfterAKeyFile(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &prompt.Scripted{ + Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, + Texts: map[string]string{ + "Path to the APNs auth key (.p8)": existing, + "What is your Apple Developer team ID?": "ABCDE12345", + }, + } + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + want := []string{"How would you like to provide the APNs auth key (.p8)?", "Path to the APNs auth key (.p8)", "What is your Apple Developer team ID?"} + if strings.Join(scripted.Asked, "|") != strings.Join(want, "|") { + t.Errorf("asked %v", scripted.Asked) + } + if messaging.providers[0]["credentials"].(map[string]any)["teamId"] != "ABCDE12345" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsSetsUpOneEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true, environment: "sandbox"} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].Environment != apns.EnvironmentSandbox { + t.Errorf("helper calls = %v", appleKey.Requests) + } + if len(messaging.providers) != 1 || messaging.providers[0]["options"].(map[string]any)["sandbox"] != true { + t.Fatalf("providers = %v", messaging.providers) + } + if !strings.Contains(out.String(), "APNs (sandbox) is set up for com.example.app.") { + t.Errorf("output:\n%s", out.String()) + } + + // The sandbox provider is all a sandbox run needs. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || !strings.Contains(out.String(), "APNs (sandbox) is already set up for com.example.app.") { + t.Errorf("calls = %v, output:\n%s", appleKey.Requests, out.String()) + } + + // Production still needs its own provider; the key already in the folder + // serves it. + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", environment: "production"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || messaging.providers[1]["options"].(map[string]any)["sandbox"] != false { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsRejectsAnUnknownEnvironment(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: "com.example.app", environment: "staging"}, appleApp{}) + if err == nil || err.Error() != `--environment must be production, sandbox or all, not "staging"` { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsPicksTheSetupFromTheFlagThenTheEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + appwrite := &apnstest.Adapter{AdapterName: "appwrite", KeyID: "APPWRITE01", TeamID: "ABCDE12345"} + expo := &apnstest.Adapter{AdapterName: "expo", KeyID: "EXPOKEY001", TeamID: "ABCDE12345"} + setup.apnsAdapters = apns.NewRegistry(appwrite, expo) + env := map[string]string{ + "APPWRITE_APNS_SETUP": "expo", + "APPWRITE_APPLE_ID": "dev@example.com", + "APPWRITE_APPLE_PASSWORD": "secret", + } + setup.getenv = func(name string) string { return env[name] } + options := apnsOptions{bundleID: "com.example.app", createKey: true} + + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(expo.Requests) != 1 || len(appwrite.Requests) != 0 { + t.Fatalf("expo = %d, appwrite = %d requests", len(expo.Requests), len(appwrite.Requests)) + } + request := expo.Requests[0] + if request.AppleID != "dev@example.com" || request.Password != "secret" || request.Asker == nil { + t.Errorf("request = %+v", request) + } + for _, line := range []string{"Signing in to the Apple Developer portal (expo).", "Using the Apple ID from APPWRITE_APPLE_ID.", "Using the Apple ID password from APPWRITE_APPLE_PASSWORD."} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if strings.Contains(out.String(), "secret") { + t.Errorf("printed the password:\n%s", out.String()) + } + + // Each run starts from no providers and a fresh folder: a key file is + // never overwritten. + fresh := func() { + messaging.providers = nil + setup.keyDirs = []string{t.TempDir()} + } + fresh() + options.setup = "appwrite" + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 1 || len(expo.Requests) != 1 { + t.Errorf("--provider did not win over %s", apnsSetupVariable) + } + + delete(env, "APPWRITE_APNS_SETUP") + options.setup = "" + fresh() + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 2 { + t.Errorf("the default is not %s", defaultApnsSetup) + } +} + +func TestInitApnsReportsAnUnknownSetup(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", teamID: "ABCDE12345", createKey: true, setup: "fastlane"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), `unknown APNs key setup "fastlane": use one of appwrite`) { + t.Fatalf("err = %v", err) + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsWithoutSetupsOnlyOffersTheOtherWays(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + var offered []prompt.Option + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, Texts: map[string]string{"Path to the APNs auth key (.p8)": testApnsKey(t, t.TempDir())}} + setup, _ := newTestPushSetup(t, server, optionsRecorder{scripted, &offered}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry() + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, option := range offered { + if option.Value == "automatic" { + t.Errorf("offered the Apple sign-in with no setup built in: %v", offered) + } + } + + messaging.providers = nil + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "this build has none") { + t.Errorf("err = %v", err) + } +} + +// optionsRecorder records the options of the key question. +type optionsRecorder struct { + *prompt.Scripted + options *[]prompt.Option +} + +func (r optionsRecorder) Choice(question prompt.Choice) (string, error) { + if question.Message == "How would you like to provide the APNs auth key (.p8)?" { + *r.options = question.Options + } + + return r.Scripted.Choice(question) +} From 4cfe2c0364e5b3bcc7b36972a5f7134dbb91d1a3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:49:42 +0530 Subject: [PATCH 05/29] fix(cli): use the created APNs key's ID, never --key-id A key created by the Apple sign-in has an ID Apple chose. --key-id could override it, and the providers then got an ID that does not match the .p8, so every push would fail to authenticate. --create-key now rejects --key-id, and a key created from the prompt always uses Apple's ID, with a warning when --key-id said otherwise. Sign-in questions asked without a terminal also no longer suggest --key-path, which --create-key rejects. The error names the question, says APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD answer the Apple ID and password, that the two-factor code always needs a terminal, and to use --key-path instead of --create-key otherwise. --- templates/cli/internal/cmd/initpush.go | 43 ++++++++----- templates/cli/internal/cmd/initpush_test.go | 67 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 85a907e01d..197c0b4d49 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -154,6 +154,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) command.MarkFlagsMutuallyExclusive("create-key", "key-path") + command.MarkFlagsMutuallyExclusive("create-key", "key-id") return command } @@ -424,9 +425,10 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { } keyPath := options.keyPath + createdKeyID := "" if keyPath == "" { var keyTeam string - keyPath, keyTeam, err = s.apnsKey(teamID, options, environment) + keyPath, createdKeyID, keyTeam, err = s.apnsKey(teamID, options, environment) if err != nil { return err } @@ -449,6 +451,13 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { } keyID := options.keyID + // A created key's ID comes from Apple; any other would not match the .p8. + if createdKeyID != "" { + if keyID != "" && keyID != createdKeyID { + output.Warn(s.out, "Using the created key's ID %s instead of --key-id %s.", createdKeyID, keyID) + } + keyID = createdKeyID + } if keyID == "" { if match := apnsKeyFileName.FindStringSubmatch(filepath.Base(keyPath)); match != nil { keyID = match[1] @@ -496,7 +505,7 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { // --create-key, signing in with the Apple ID is the only way, and a failure // stops the command. Otherwise it is offered alongside the browser flow, and // when it fails the other ways of providing the key remain. -func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, error) { +func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { create := options.createKey explicit := create automatic := "" @@ -513,12 +522,12 @@ func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns return strings.HasSuffix(path, ".p8") && err == nil }) if !errors.Is(err, errAutomaticKey) { - return path, "", err + return path, "", "", err } } create = false - path, keyTeam, err := s.createApnsKey(teamID, options.setup, environment) + path, keyID, keyTeam, err := s.createApnsKey(teamID, options.setup, environment) var maxKeys *apns.MaxKeysError if errors.As(err, &maxKeys) { output.Warn(s.out, "Apple allows two APNs keys per team, and this team has no free slot. Keys on the team:") @@ -527,15 +536,15 @@ func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns } output.Hint(s.out, "One key works for every app on the team. Reuse a .p8 you saved earlier, or revoke a key you no longer use at %s.", apnsKeysListPage) - return "", "", errors.New("no APNs key can be created on this team. Pass --key-path with an existing .p8 instead") + return "", "", "", errors.New("no APNs key can be created on this team. Pass --key-path with an existing .p8 instead") } if err == nil { - return path, keyTeam, nil + return path, keyID, keyTeam, nil } // --create-key asked for a new key: falling back to a .p8 already in // the project folder would hide that none was created. if explicit { - return "", "", fmt.Errorf("could not create the APNs key with your Apple ID: %w", err) + return "", "", "", fmt.Errorf("could not create the APNs key with your Apple ID: %w", err) } output.Warn(s.out, "Could not create the APNs key with your Apple ID: %s.", err) output.Log(s.out, "Provide the key another way instead.") @@ -619,7 +628,7 @@ type apnsAsker struct { } func (a apnsAsker) Ask(question string, secret bool) (string, error) { - return a.prompter.Text(prompt.Text{Message: question, Secret: secret, Flag: "--key-path", Validate: requiredValue("an answer")}) + return a.prompter.Text(prompt.Text{Message: question, Secret: secret, Validate: requiredValue("an answer")}) } func (a apnsAsker) Choose(question string, options []string) (int, error) { @@ -627,7 +636,7 @@ func (a apnsAsker) Choose(question string, options []string) (int, error) { for index, label := range options { choices[index] = prompt.Option{Label: label, Value: strconv.Itoa(index)} } - value, err := a.prompter.Choice(prompt.Choice{Message: question, Options: choices, Flag: "--key-path"}) + value, err := a.prompter.Choice(prompt.Choice{Message: question, Options: choices}) if err != nil { return 0, err } @@ -638,10 +647,10 @@ func (a apnsAsker) Choose(question string, options []string) (int, error) { // 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, setup string, environment apns.Environment) (string, string, error) { +func (s *pushSetup) createApnsKey(teamID, setup string, environment apns.Environment) (string, string, string, error) { adapter, err := s.apnsAdapter(setup) if err != nil { - return "", "", err + return "", "", "", err } request := apns.Request{ TeamID: teamID, @@ -665,8 +674,12 @@ func (s *pushSetup) createApnsKey(teamID, setup string, environment apns.Environ output.Log(s.out, "Using the Apple ID password from %s.", applePasswordVariable) } key, err := apns.CreateKey(s.context(), adapter, request) + 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) + } if err != nil { - return "", "", err + return "", "", "", err } directory := s.root @@ -676,21 +689,21 @@ func (s *pushSetup) createApnsKey(teamID, setup string, environment apns.Environ path := filepath.Join(directory, "AuthKey_"+key.KeyID+".p8") file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { - return "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) } _, err = file.WriteString(key.P8) if closeErr := file.Close(); err == nil { err = closeErr } if err != nil { - return "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) } output.Success(s.out, "Created APNs key %s on team %s and saved it to %s.", key.KeyID, key.TeamID, s.display(path)) output.Hint(s.out, "Apple lets you download this key only once. Keep the file somewhere safe and out of version control.") s.checkKeyLocation(path) - return path, key.TeamID, nil + return path, key.KeyID, key.TeamID, nil } func (s *pushSetup) fcm(options fcmOptions, detected androidApp) error { diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 47d68c80b8..97a23630b7 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -1612,3 +1613,69 @@ func (r optionsRecorder) Choice(question prompt.Choice) (string, error) { return r.Scripted.Choice(question) } + +func TestInitApnsUsesTheCreatedKeysID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "automatic"}} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", keyID: "WRONGKEY12"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "NEWKEY1234" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Using the created key's ID NEWKEY1234 instead of --key-id WRONGKEY12.") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--create-key", "--key-id", "WRONGKEY12"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-id") { + t.Errorf("--create-key with --key-id: %v", err) + } +} + +// askingAdapter asks one question before creating a key, as a sign-in does. +type askingAdapter struct{} + +func (askingAdapter) Name() string { return defaultApnsSetup } + +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 + } + + return apns.Key{KeyID: "NEWKEY1234", TeamID: request.TeamID, P8: apnstest.P8()}, nil +} + +func TestInitApnsExplainsASignInWithoutATerminal(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, prompt.NonInteractive{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(askingAdapter{}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil { + t.Fatal("no error without a terminal") + } + message := err.Error() + for _, part := range []string{`asked "Enter the 6-digit code sent to +1 •••12"`, "APPWRITE_APPLE_ID", "the two-factor code always needs one", "instead of --create-key"} { + if !strings.Contains(message, part) { + t.Errorf("message is missing %q: %s", part, message) + } + } + if strings.Contains(message, "Pass --key-path instead") { + t.Errorf("suggests a flag --create-key rejects: %s", message) + } +} From f2d5d96a33e057520dc73a84416ca042d92c3cc1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:55:21 +0530 Subject: [PATCH 06/29] feat(cli): add Appwrite's own APNs key setup The appwrite adapter signs in to Apple and calls the Developer portal in Go, with nothing to install. The sign-in follows spaceship: the widget key from the App Store Connect sign out redirect, SRP-6a against idmsa.apple.com (PBKDF2 over the password, s2k and s2k_fo), hashcash, two-factor by device code or text message, and a trusted session kept in the request's session folder so later runs skip two-factor. The key calls follow @expo/apple-utils: list the team's keys, which hands out the CSRF tokens, create a team-scoped key with APNs for the requested environment, and download the .p8. The SRP and hashcash code is checked against values computed by fastlane-sirp and spaceship, and the whole flow against a fake Apple that runs the server side of SRP. The Go bodies of portal.go and adapter_test.go are Twig verbatim blocks, as their nested composite literals read as Twig tags. --- src/SDK/Language/CLI.php | 35 ++ .../cli/internal/apns/appwrite/adapter.go | 221 +++++++ .../internal/apns/appwrite/adapter_test.go | 583 ++++++++++++++++++ templates/cli/internal/apns/appwrite/auth.go | 487 +++++++++++++++ templates/cli/internal/apns/appwrite/jar.go | 129 ++++ .../cli/internal/apns/appwrite/portal.go | 226 +++++++ templates/cli/internal/apns/appwrite/srp.go | 174 ++++++ .../cli/internal/apns/appwrite/srp_test.go | 109 ++++ 8 files changed, 1964 insertions(+) create mode 100644 templates/cli/internal/apns/appwrite/adapter.go create mode 100644 templates/cli/internal/apns/appwrite/adapter_test.go create mode 100644 templates/cli/internal/apns/appwrite/auth.go create mode 100644 templates/cli/internal/apns/appwrite/jar.go create mode 100644 templates/cli/internal/apns/appwrite/portal.go create mode 100644 templates/cli/internal/apns/appwrite/srp.go create mode 100644 templates/cli/internal/apns/appwrite/srp_test.go diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 946e66a73e..dfa826f90a 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1161,6 +1161,41 @@ public function getFiles(): array 'destination' => 'internal/apns/apnstest/apnstest.go', 'template' => 'cli/internal/apns/apnstest/apnstest.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/adapter.go', + 'template' => 'cli/internal/apns/appwrite/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/adapter_test.go', + 'template' => 'cli/internal/apns/appwrite/adapter_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/auth.go', + 'template' => 'cli/internal/apns/appwrite/auth.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/jar.go', + 'template' => 'cli/internal/apns/appwrite/jar.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/portal.go', + 'template' => 'cli/internal/apns/appwrite/portal.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp.go', + 'template' => 'cli/internal/apns/appwrite/srp.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp_test.go', + 'template' => 'cli/internal/apns/appwrite/srp_test.go', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go new file mode 100644 index 0000000000..657751ce6b --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -0,0 +1,221 @@ +// Package appwrite is Appwrite's own APNs key setup, in Go with no runtime to +// install. +// +// It signs in the way the Apple Developer portal's web pages do, with the +// person's Apple ID, password and two-factor code, and calls the portal's +// private API. The sign-in follows spaceship (fastlane), which keeps up with +// Apple's changes in readable code, and the key calls follow +// @expo/apple-utils. +package appwrite + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter creates APNs keys with Appwrite's own sign-in. +type Adapter struct { + // For tests. Zero values mean Apple's servers and the real clock. + HTTP *http.Client + Hosts Hosts + Random io.Reader + Now func() time.Time +} + +// New returns the adapter with Apple's servers. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "appwrite" +} + +// CreateKey signs in, creates a team-scoped APNs key and downloads it. The +// signed-in session is kept in request.SessionDir, when set, so later runs +// skip two-factor. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + portal, err := a.connect(ctx, request) + if err != nil { + return apns.Key{}, err + } + defer portal.saveSession() + + teamID, err := portal.chooseTeam(ctx, request.TeamID) + if err != nil { + return apns.Key{}, err + } + existing, err := portal.keys(ctx, teamID) + if err != nil { + return apns.Key{}, err + } + keyID, err := portal.createKey(ctx, teamID, request.Name, request.Environment) + if errors.Is(err, errMaxKeys) { + return apns.Key{}, &apns.MaxKeysError{Keys: existing} + } + if err != nil { + return apns.Key{}, err + } + p8, err := portal.downloadKey(ctx, teamID, keyID) + if err != nil { + 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 +} + +func sessionPath(dir string) string { + if dir == "" { + return "" + } + + return filepath.Join(dir, "session.json") +} + +// saveSession keeps the cookies for the next run. Without a session folder +// the session lives only as long as this run. +func (p *portalClient) saveSession() { + if path := sessionPath(p.dir); path != "" { + if err := p.jar.save(path); err != nil { + p.log("Could not save the Apple sign-in, so the next run asks again: %v", err) + } + } +} + +// connect returns a signed-in portal client, reusing the saved session when +// it is still valid and belongs to the Apple ID being used. +func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalClient, error) { + if request.SessionDir != "" { + if err := os.MkdirAll(request.SessionDir, 0o700); err != nil { + return nil, err + } + } + httpClient := a.HTTP + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Minute} + } + jar := newSavedJar() + if path := sessionPath(request.SessionDir); path != "" { + jar.load(path) + } + withJar := *httpClient + withJar.Jar = jar + logf := request.Log + if logf == nil { + logf = func(string, ...any) {} + } + portal := &portalClient{ + http: &withJar, jar: jar, hosts: a.Hosts, dir: request.SessionDir, asker: request.Asker, log: logf, + random: a.Random, now: a.Now, + } + if portal.hosts == (Hosts{}) { + portal.hosts = DefaultHosts + } + if portal.random == nil { + portal.random = rand.Reader + } + if portal.now == nil { + portal.now = time.Now + } + + accountName := strings.TrimSpace(request.AppleID) + 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) + + return portal, nil + } + // Another Apple ID's session: none of it applies. + jar = newSavedJar() + withJar.Jar = jar + portal.jar = jar + } + // 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 { + return nil, err + } + + if accountName == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, err + } + accountName = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+accountName, true) + if err != nil { + return nil, err + } + password = answer + } + + if err := portal.signIn(ctx, accountName, password); err != nil { + return nil, err + } + // Saved as soon as Apple accepts the sign-in, so a failure after this + // point does not cost another two-factor code. + portal.saveSession() + session, err := portal.session(ctx) + if err != nil { + return nil, fmt.Errorf("could not reach App Store Connect: %w", err) + } + 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") + } + + return portal, nil +} + +// chooseTeam checks teamID against the account's teams, or picks one. +func (p *portalClient) chooseTeam(ctx context.Context, teamID string) (string, error) { + teams, err := p.teams(ctx) + if errors.Is(err, errSessionExpired) { + return "", errors.New("the Apple session expired. Run the command again to sign in") + } + if err != nil { + return "", err + } + if len(teams) == 0 { + return "", errors.New("this Apple ID belongs to no Apple Developer team. APNs keys need a paid Apple Developer Program membership") + } + if teamID != "" { + ids := make([]string, len(teams)) + for index, team := range teams { + if team.ID == teamID { + return teamID, nil + } + ids[index] = team.ID + } + + return "", fmt.Errorf("this Apple ID is not on team %s. Its teams are %s", teamID, strings.Join(ids, ", ")) + } + if len(teams) == 1 { + p.log("Using Apple team %s (%s).", teams[0].Name, teams[0].ID) + + return teams[0].ID, nil + } + labels := make([]string, len(teams)) + for index, team := range teams { + labels[index] = fmt.Sprintf("%s (%s)", team.Name, team.ID) + } + chosen, err := p.asker.Choose("Which Apple Developer team should own the key?", labels) + if err != nil { + return "", err + } + + return teams[chosen].ID, nil +} diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go new file mode 100644 index 0000000000..9e95e0bb5a --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -0,0 +1,583 @@ +package appwrite + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +const ( + testAppleID = "dev@example.com" + testPassword = "correct horse battery staple" + testCode = "123456" + testP8 = "-----BEGIN PRIVATE KEY-----\nMIGT\n-----END PRIVATE KEY-----" +) + +// fakeApple plays idmsa.apple.com, App Store Connect and the Developer +// portal: it runs the server side of SRP, so a wrong password or a wrong +// proof fails the way it would against Apple. +type fakeApple struct { + mu sync.Mutex + t *testing.T + requests []string + + widgetKeyGone bool + noTwoFactor bool + noTrustedDevices bool + olympusRefuses bool + // generation names the current signed-in cookie; bumping it expires + // every session handed out before. + generation int + teams []Team + keys []apns.ExistingKey + maxKeys bool + + salt []byte + b *big.Int + serverB []byte + clientA []byte + verifier *big.Int + created map[string]map[string]any +} + +func newFakeApple(t *testing.T) *fakeApple { + return &fakeApple{ + t: t, + teams: []Team{{ID: "ABCDE12345", Name: "Example Inc", Type: "Company/Organization"}}, + salt: []byte("0123456789abcdef"), + created: map[string]map[string]any{}, + } +} + +func (f *fakeApple) record(r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) +} + +func (f *fakeApple) signedIn(r *http.Request) bool { + cookie, err := r.Cookie("myacinfo") + + return err == nil && cookie.Value == f.sessionCookie().Value +} + +func (f *fakeApple) sessionCookie() *http.Cookie { + return &http.Cookie{Name: "myacinfo", Value: fmt.Sprintf("signed-in-%d", f.generation), Path: "/"} +} + +// trusted reports whether the request carries the cookie 2sv/trust handed +// out, quoted the way Apple quotes it. +func (f *fakeApple) trusted(r *http.Request) bool { + cookie, err := r.Cookie("DES123") + + return err == nil && cookie.Value == "trusted==SRVT" && cookie.Quoted +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.record(r) + body, _ := io.ReadAll(r.Body) + var params map[string]any + _ = json.Unmarshal(body, ¶ms) + idmsa := r.Header.Get("X-Apple-Widget-Key") == "widget-key" + twoFactorSession := r.Header.Get("X-Apple-Id-Session-Id") == "session-1" && r.Header.Get("scnt") == "scnt-1" + + switch { + case r.Method == http.MethodHead && r.URL.Path == "/logout": + if len(r.Cookies()) > 0 { + f.t.Error("the widget key request sent cookies, which would sign the session out") + } + if !f.widgetKeyGone { + w.Header().Set("Location", "https://idmsa.apple.com/IDMSWebAuth/signout?widgetKey=widget-key&path=/") + } + w.WriteHeader(http.StatusFound) + + case r.URL.Path == "/olympus/v1/app/config": + w.WriteHeader(http.StatusNotFound) + + case r.URL.Path == "/appleauth/auth/signin/init" && idmsa: + f.clientA, _ = base64.StdEncoding.DecodeString(params["a"].(string)) + key, _ := passwordKey(testPassword, f.salt, 1000, "s2k") + x := new(big.Int).SetBytes(sha256Of(f.salt, sha256Of([]byte(":"), key))) + f.verifier = new(big.Int).Exp(srpG, x, srpN) + f.b = big.NewInt(987654321) + B := new(big.Int).Mul(hashPadded(srpN.Bytes(), srpG.Bytes()), f.verifier) + B.Add(B, new(big.Int).Exp(srpG, f.b, srpN)) + B.Mod(B, srpN) + f.serverB = B.Bytes() + writeJSON(w, http.StatusOK, map[string]any{ + "iteration": 1000, "salt": base64.StdEncoding.EncodeToString(f.salt), "protocol": "s2k", + "b": base64.StdEncoding.EncodeToString(f.serverB), "c": "challenge-c", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth/signin": + w.Header().Set("X-Apple-HC-Bits", "8") + w.Header().Set("X-Apple-HC-Challenge", "hc-challenge") + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/signin/complete" && idmsa: + token := r.Header.Get("X-Apple-HC") + if !strings.Contains(token, ":hc-challenge::") || leadingZeroBits(sha1.Sum([]byte(token))) < 8 { + f.t.Errorf("hashcash = %q", token) + } + if params["c"] != "challenge-c" || params["accountName"] != testAppleID { + f.t.Errorf("complete params = %v", params) + } + // The server's side of SRP-6a: S = (A * v^u) ^ b mod N. + A := new(big.Int).SetBytes(f.clientA) + u := hashPadded(f.clientA, f.serverB) + S := new(big.Int).Exp(f.verifier, u, srpN) + S.Mul(S, A) + S.Exp(S, f.b, srpN) + K := sha256Of(S.Bytes()) + xor := new(big.Int).Xor(hashPadded(srpN.Bytes()), hashPadded(srpG.Bytes())) + expected := sha256Of(xor.Bytes(), sha256Of([]byte(testAppleID)), f.salt, f.clientA, f.serverB, K) + m1, _ := base64.StdEncoding.DecodeString(params["m1"].(string)) + if !bytes.Equal(m1, expected) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"serviceErrors": []map[string]string{{"code": "-20101", "message": "Your Apple ID or password was incorrect."}}}) + + return + } + if f.noTwoFactor || f.trusted(r) { + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + return + } + w.Header().Set("X-Apple-Id-Session-Id", "session-1") + w.Header().Set("scnt", "scnt-1") + writeJSON(w, http.StatusConflict, map[string]any{"authType": "hsa2"}) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth" && twoFactorSession: + writeJSON(w, http.StatusOK, map[string]any{ + "trustedPhoneNumbers": []map[string]any{{"id": 1, "numberWithDialCode": "+1 (•••) •••-••12", "pushMode": "sms"}}, + "securityCode": map[string]any{"length": 6}, + "noTrustedDevices": f.noTrustedDevices, + }) + + case r.URL.Path == "/appleauth/auth/verify/trusteddevice/securitycode" && twoFactorSession: + code := params["securityCode"].(map[string]any)["code"] + if code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusNoContent) + + case r.URL.Path == "/appleauth/auth/verify/phone/securitycode" && twoFactorSession: + phone, _ := params["phoneNumber"].(map[string]any) + code := params["securityCode"].(map[string]any)["code"] + if phone["id"] != float64(1) || params["mode"] != "sms" || code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/2sv/trust" && twoFactorSession: + http.SetCookie(w, &http.Cookie{Name: "DES123", Value: "trusted==SRVT", Quoted: true, Path: "/"}) + w.WriteHeader(http.StatusNoContent) + + case r.URL.Path == "/olympus/v1/session": + if !f.signedIn(r) || f.olympusRefuses { + w.WriteHeader(http.StatusUnauthorized) + + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user": map[string]any{"emailAddress": testAppleID}, "provider": map[string]any{"providerId": 1, "name": "Example Inc"}, + }) + + case strings.HasPrefix(r.URL.Path, "/services-account/QH65B2/"): + if !f.signedIn(r) { + w.WriteHeader(http.StatusUnauthorized) + + return + } + f.portal(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/QH65B2/"), params) + + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } +} + +func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { + switch path { + case "account/listTeams.action": + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "teams": f.teams}) + case "account/auth/key/list": + if params["teamId"] == nil || params["pageSize"] == nil { + f.t.Errorf("list params = %v", params) + } + w.Header().Set("csrf", "csrf-token") + w.Header().Set("csrf_ts", "csrf-ts") + keys := []map[string]any{} + for _, key := range f.keys { + keys = append(keys, map[string]any{"keyId": key.ID, "keyName": key.Name, "canRevoke": key.CanRevoke}) + } + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": keys}) + case "account/auth/key/v2/create": + if r.Header.Get("csrf") != "csrf-token" || r.Header.Get("csrf_ts") != "csrf-ts" { + f.t.Errorf("create sent csrf %q / %q", r.Header.Get("csrf"), r.Header.Get("csrf_ts")) + } + if f.maxKeys { + writeJSON(w, http.StatusOK, map[string]any{ + "resultCode": 9401, + "resultString": "You%20have%20already%20reached%20the%20maximum%20allowed%20number%20of%20Keys%20for%20this%20service.", + }) + + return + } + f.created["NEWKEY1234"] = params + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": []map[string]any{{"keyId": "NEWKEY1234", "keyName": params["name"]}}}) + case "account/auth/key/download": + if r.URL.Query().Get("keyId") != "NEWKEY1234" || r.URL.Query().Get("teamId") == "" { + f.t.Errorf("download query = %v", r.URL.Query()) + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(testP8)) + default: + f.t.Errorf("unexpected portal call %s", path) + w.WriteHeader(http.StatusNotFound) + } +} + +type scriptedAsker struct { + answers map[string][]string + choices map[string]int + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answers := range s.answers { + if strings.HasPrefix(question, prefix) && len(answers) > 0 { + s.answers[prefix] = answers[1:] + + return answers[0], nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(question string, options []string) (int, error) { + s.asked = append(s.asked, question) + if index, ok := s.choices[question]; ok { + return index, nil + } + + return 0, errors.New("no choice for " + question) +} + +// testClient runs the adapter against the fake with a fixed request, the way +// the CLI does. +type testClient struct { + adapter *Adapter + request apns.Request +} + +func (c testClient) CreateKey(ctx context.Context, teamID, name string, environment apns.Environment) (apns.Key, error) { + request := c.request + request.TeamID, request.Name, request.Environment = teamID, name, environment + + return c.adapter.CreateKey(ctx, request) +} + +// newTestClient points the adapter at fake. credentials holds the Apple ID +// and password the CLI would pass from APPWRITE_APPLE_ID and +// APPWRITE_APPLE_PASSWORD. +func newTestClient(t *testing.T, fake *fakeApple, asker *scriptedAsker, dir string, credentials map[string]string) (testClient, *[]string) { + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + logged := &[]string{} + + return testClient{ + adapter: &Adapter{ + HTTP: server.Client(), + Hosts: Hosts{Idmsa: server.URL, AppStoreConnect: server.URL, Portal: server.URL}, + Now: func() time.Time { return time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) }, + }, + request: apns.Request{ + AppleID: credentials["APPWRITE_APPLE_ID"], + Password: credentials["APPWRITE_APPLE_PASSWORD"], + SessionDir: dir, + Asker: asker, + Log: func(format string, args ...any) { + *logged = append(*logged, strings.TrimSpace(fmt.Sprintf(format, args...))) + }, + }, + }, logged +} + +func TestCreateKeySignsInWithTwoFactorAndReusesTheSession(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{ + "Apple ID": {testAppleID}, + "Password for": {testPassword}, + "Enter the 6-di": {"000000", testCode}, + }} + client, logged := newTestClient(t, fake, asker, dir, nil) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 { + t.Errorf("key = %+v", key) + } + created := fake.created["NEWKEY1234"] + configurations, _ := json.Marshal(created["serviceConfigurations"]) + requests, _ := json.Marshal(created["serviceConfigurationsRequests"]) + if created["teamId"] != "ABCDE12345" || created["name"] != "Appwrite Push" || created["scope"] != "team" || + string(configurations) != `{"U27F4V844T":[]}` || + string(requests) != `[{"environment":"all","identifiers":{},"isNew":true,"scope":"team","serviceId":"U27F4V844T"}]` { + t.Errorf("create params = %v", created) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for "+testAppleID+"|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)" { + t.Errorf("asked %v", asker.asked) + } + if !contains(*logged, "That code is not right. Try again.") || !contains(*logged, "Using Apple team Example Inc (ABCDE12345).") { + t.Errorf("logged %v", *logged) + } + info, err := os.Stat(filepath.Join(dir, "session.json")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("session file = %v, %v", info, err) + } + + // A second run reuses the trusted session: no questions, no sign-in. + again := &scriptedAsker{} + second, logged := newTestClient(t, fake, again, dir, nil) + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push 2", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + for _, request := range fake.requests { + if strings.Contains(request, "/appleauth/") { + t.Errorf("signed in again: %v", fake.requests) + + break + } + } + if !contains(*logged, "Using the saved Apple sign-in for "+testAppleID+".") { + t.Errorf("logged %v", *logged) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + asker := &scriptedAsker{} + client, logged := newTestClient(t, fake, asker, t.TempDir(), map[string]string{ + "APPWRITE_APPLE_ID": testAppleID, + "APPWRITE_APPLE_PASSWORD": testPassword, + }) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } + for _, line := range *logged { + if strings.Contains(line, testPassword) { + t.Errorf("logged the password: %v", *logged) + } + } +} + +func TestCreateKeyRejectsAWrongPassword(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{"Apple ID": {testAppleID}, "Password for": {"wrong"}}} + client, _ := newTestClient(t, fake, asker, dir, nil) + + if _, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err == nil { + t.Error("saved a session after a failed sign-in") + } +} + +func TestCreateKeyReportsAFullTeam(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.maxKeys = true + fake.keys = []apns.ExistingKey{{ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 2 || maxKeys.Keys[0].ID != "OLDKEY1234" || maxKeys.Keys[1].Name != "Expo" { + t.Fatalf("err = %v", err) + } + if len(fake.created) != 0 { + t.Errorf("created %v", fake.created) + } +} + +func TestCreateKeyChoosesAmongTeams(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.teams = []Team{{ID: "AAAAA11111", Name: "Personal"}, {ID: "ABCDE12345", Name: "Example Inc"}} + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + asker := &scriptedAsker{choices: map[string]int{"Which Apple Developer team should own the key?": 1}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), env) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil || key.TeamID != "ABCDE12345" { + t.Fatalf("key = %+v, %v", key, err) + } + + other, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + if _, err := other.CreateKey(context.Background(), "ZZZZZ99999", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "AAAAA11111, ABCDE12345") { + t.Errorf("err = %v", err) + } +} + +func TestWidgetKeyFallsBackToTheSavedCopy(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.widgetKeyGone = true + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "widget-key"), []byte("widget-key\n"), 0o600); err != nil { + t.Fatal(err) + } + client, _ := newTestClient(t, fake, &scriptedAsker{}, dir, map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + empty, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := empty.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "sign-in key") { + t.Errorf("err = %v", err) + } +} + +func contains(lines []string, line string) bool { + for _, candidate := range lines { + if candidate == line { + return true + } + } + + return false +} + +func TestCreateKeyWithATextMessageCode(t *testing.T) { + fake := newFakeApple(t) + fake.noTrustedDevices = true + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-digit code sent to": {testCode}}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Enter the 6-digit code sent to +1 (•••) •••-••12" { + t.Errorf("asked %v", asker.asked) + } + requests := strings.Join(fake.requests, ",") + // With no trusted devices and one phone, Apple has already texted the code. + if !strings.Contains(requests, "POST /appleauth/auth/verify/phone/securitycode") || strings.Contains(requests, "PUT /appleauth/auth/verify/phone,") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestTheSignInIsSavedAsSoonAsTheCodeIsAccepted(t *testing.T) { + fake := newFakeApple(t) + fake.olympusRefuses = true + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "refused the session") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err != nil { + t.Fatalf("the accepted sign-in was not saved: %v", err) + } + + fake.olympusRefuses = false + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } +} + +func TestAnExpiredSessionSignsInAgainWithoutACode(t *testing.T) { + fake := newFakeApple(t) + 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) + } + + fake.generation++ + fake.requests = nil + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || strings.Contains(requests, "/securitycode") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestCreateKeyForOneEnvironment(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentSandbox); err != nil { + t.Fatal(err) + } + requests := fake.created["NEWKEY1234"]["serviceConfigurationsRequests"].([]any) + if environment := requests[0].(map[string]any)["environment"]; environment != "sandbox" { + t.Errorf("environment = %v", environment) + } + +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go new file mode 100644 index 0000000000..971fa5c321 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -0,0 +1,487 @@ +package appwrite + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// The sign-in follows spaceship (fastlane), which keeps up with Apple's +// changes in readable code: the widget key from the sign out redirect, SRP +// against idmsa.apple.com, hashcash, then two-factor and a trusted session. + +// Hosts are the Apple servers the client talks to. Tests point them at a fake. +type Hosts struct { + Idmsa string + AppStoreConnect string + Portal string +} + +// DefaultHosts are Apple's production servers. +var DefaultHosts = Hosts{ + Idmsa: "https://idmsa.apple.com", + AppStoreConnect: "https://appstoreconnect.apple.com", + Portal: "https://developer.apple.com", +} + +type portalClient struct { + http *http.Client + jar *savedJar + hosts Hosts + dir string + asker apns.Asker + log func(string, ...any) + random io.Reader + now func() time.Time + + widgetKey string + sessionID string + scnt string + csrf string + csrfTS string +} + +type olympusSession struct { + User struct { + EmailAddress string `json:"emailAddress"` + } `json:"user"` + Provider *struct { + ProviderID int64 `json:"providerId"` + Name string `json:"name"` + } `json:"provider"` +} + +type serviceErrors struct { + ServiceErrors []struct { + Code string `json:"code"` + Title string `json:"title"` + Message string `json:"message"` + } `json:"serviceErrors"` +} + +func (e serviceErrors) message() string { + for _, failure := range e.ServiceErrors { + if failure.Message != "" { + return failure.Message + } + if failure.Title != "" { + return failure.Title + } + } + + return "" +} + +func (e serviceErrors) has(code string) bool { + for _, failure := range e.ServiceErrors { + if failure.Code == code { + return true + } + } + + return false +} + +// request sends one request and reads the whole response. +func (p *portalClient) request(ctx context.Context, method, target string, body any, headers map[string]string) (*http.Response, []byte, error) { + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return nil, nil, err + } + reader = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, method, target, reader) + if err != nil { + return nil, nil, err + } + request.Header.Set("User-Agent", "Appwrite CLI") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + for name, value := range headers { + request.Header.Set(name, value) + } + response, err := p.http.Do(request) + if err != nil { + return nil, nil, err + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + return nil, nil, err + } + + return response, payload, nil +} + +// idmsaHeaders are sent with every sign-in request. After Apple asks for +// two-factor, they also carry the session it handed out. +func (p *portalClient) idmsaHeaders() map[string]string { + headers := map[string]string{ + "Accept": "application/json, text/javascript", + "X-Requested-With": "XMLHttpRequest", + "X-Apple-Widget-Key": p.widgetKey, + } + if p.sessionID != "" { + headers["Accept"] = "application/json" + headers["X-Apple-Id-Session-Id"] = p.sessionID + headers["scnt"] = p.scnt + } + + return headers +} + +// session asks App Store Connect who is signed in. It returns nil when the +// cookies do not hold a usable session. +func (p *portalClient) session(ctx context.Context) (*olympusSession, error) { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.AppStoreConnect+"/olympus/v1/session", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return nil, err + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, nil + } + var session olympusSession + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &session) != nil || session.Provider == nil { + return nil, nil + } + + return &session, nil +} + +// loadWidgetKey reads the key App Store Connect's web app signs in with from +// the sign out redirect, then from the copy saved by an earlier run, then from +// the endpoint Apple used to document (removed in September 2026). +func (p *portalClient) loadWidgetKey(ctx context.Context) error { + // Without a session folder there is nowhere to keep a copy. + cache := "" + if p.dir != "" { + cache = filepath.Join(p.dir, "widget-key") + } + if key := p.widgetKeyFromSignOut(ctx); key != "" { + p.widgetKey = key + p.cacheWidgetKey(cache) + + return nil + } + if cached, err := os.ReadFile(cache); cache != "" && err == nil && len(bytes.TrimSpace(cached)) > 0 { + p.widgetKey = string(bytes.TrimSpace(cached)) + + return nil + } + + response, payload, err := p.request(ctx, http.MethodGet, + p.hosts.AppStoreConnect+"/olympus/v1/app/config?hostname=itunesconnect.apple.com", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return fmt.Errorf("could not reach App Store Connect: %w", err) + } + var config struct { + AuthServiceKey string `json:"authServiceKey"` + } + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &config) != nil || config.AuthServiceKey == "" { + return fmt.Errorf("could not read App Store Connect's sign-in key (HTTP %d)", response.StatusCode) + } + p.widgetKey = config.AuthServiceKey + p.cacheWidgetKey(cache) + + return nil +} + +func (p *portalClient) cacheWidgetKey(cache string) { + if cache != "" { + _ = os.WriteFile(cache, []byte(p.widgetKey), 0o600) + } +} + +// widgetKeyFromSignOut must neither send the session cookies nor follow the +// redirect: following it signs the session out. +func (p *portalClient) widgetKeyFromSignOut(ctx context.Context) string { + bare := &http.Client{ + Transport: p.http.Transport, + Timeout: p.http.Timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + request, err := http.NewRequestWithContext(ctx, http.MethodHead, p.hosts.AppStoreConnect+"/logout", nil) + if err != nil { + return "" + } + request.Header.Set("User-Agent", "Appwrite CLI") + response, err := bare.Do(request) + if err != nil { + return "" + } + response.Body.Close() + location, err := url.Parse(response.Header.Get("Location")) + if err != nil { + return "" + } + + return location.Query().Get("widgetKey") +} + +// signIn runs the SRP sign-in and, when Apple asks for it, two-factor. +func (p *portalClient) signIn(ctx context.Context, accountName, password string) error { + client, err := newSRPClient(p.random) + if err != nil { + return err + } + response, payload, err := p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/init", map[string]any{ + "a": base64.StdEncoding.EncodeToString(client.A), + "accountName": accountName, + "protocols": []string{"s2k", "s2k_fo"}, + }, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var challenge struct { + serviceErrors + Iteration int `json:"iteration"` + Salt string `json:"salt"` + Protocol string `json:"protocol"` + B string `json:"b"` + C string `json:"c"` + } + if err := json.Unmarshal(payload, &challenge); err != nil || response.StatusCode != http.StatusOK || challenge.B == "" { + if message := challenge.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } + salt, saltErr := base64.StdEncoding.DecodeString(challenge.Salt) + serverB, bErr := base64.StdEncoding.DecodeString(challenge.B) + if saltErr != nil || bErr != nil { + return errors.New("Apple sent an unreadable sign-in challenge") + } + key, err := passwordKey(password, salt, challenge.Iteration, challenge.Protocol) + if err != nil { + return err + } + m1, m2, err := client.proofs(accountName, key, salt, serverB) + if err != nil { + return err + } + + headers := p.idmsaHeaders() + if token := p.hashcash(ctx); token != "" { + headers["X-Apple-HC"] = token + } + response, payload, err = p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/complete?isRememberMeEnabled=false", map[string]any{ + "accountName": accountName, + "c": challenge.C, + "m1": base64.StdEncoding.EncodeToString(m1), + "m2": base64.StdEncoding.EncodeToString(m2), + "rememberMe": false, + }, headers) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + + switch response.StatusCode { + case http.StatusOK: + return nil + case http.StatusConflict: + p.sessionID = response.Header.Get("X-Apple-Id-Session-Id") + p.scnt = response.Header.Get("scnt") + + return p.twoFactor(ctx) + case http.StatusUnauthorized, http.StatusForbidden: + return apns.ErrInvalidCredentials + case http.StatusPreconditionFailed: + return errors.New("Apple needs you to accept its terms or update your account first. Sign in at https://appleid.apple.com, then try again") + default: + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } +} + +// hashcash answers the proof-of-work challenge Apple hands out on the sign-in +// page. Without one, Apple decides; spaceship carries on in that case too. +func (p *portalClient) hashcash(ctx context.Context) string { + response, _, err := p.request(ctx, http.MethodGet, + p.hosts.Idmsa+"/appleauth/auth/signin?widgetKey="+url.QueryEscape(p.widgetKey), nil, nil) + if err != nil { + return "" + } + bits, err := strconv.Atoi(response.Header.Get("X-Apple-HC-Bits")) + challenge := response.Header.Get("X-Apple-HC-Challenge") + if err != nil || challenge == "" || bits < 0 || bits > 64 { + return "" + } + + return hashcash(bits, challenge, p.now()) +} + +type trustedPhone struct { + ID int64 `json:"id"` + NumberWithDialCode string `json:"numberWithDialCode"` + PushMode string `json:"pushMode"` +} + +const codeAttempts = 3 + +// twoFactor asks for the code Apple sent to the person's devices or phone, +// checks it, and marks the session trusted so later runs skip this step. +func (p *portalClient) twoFactor(ctx context.Context) error { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth", nil, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var options struct { + serviceErrors + TrustedDevices []any `json:"trustedDevices"` + TrustedPhoneNumbers []trustedPhone `json:"trustedPhoneNumbers"` + NoTrustedDevices bool `json:"noTrustedDevices"` + SecurityCode struct { + Length int `json:"length"` + } `json:"securityCode"` + } + if json.Unmarshal(payload, &options) != nil || response.StatusCode >= 300 { + return fmt.Errorf("Apple asked for two-factor authentication but did not say how (HTTP %d)", response.StatusCode) + } + if len(options.TrustedPhoneNumbers) == 0 { + if len(options.TrustedDevices) > 0 { + return errors.New("this Apple ID uses the older two-step verification. Turn on two-factor authentication at https://appleid.apple.com, then try again") + } + if message := options.message(); message != "" { + return fmt.Errorf("Apple could not send a verification code: %s", message) + } + + return errors.New("Apple asked for two-factor authentication but offered no way to receive a code") + } + length := options.SecurityCode.Length + if length == 0 { + length = 6 + } + + // A code was pushed to the trusted devices, unless there are none. With no + // devices and a single phone, Apple has already texted it. + var phone *trustedPhone + question := fmt.Sprintf("Enter the %d-digit code shown on your Apple device (or type sms to get a text message)", length) + if options.NoTrustedDevices { + if len(options.TrustedPhoneNumbers) == 1 { + phone = &options.TrustedPhoneNumbers[0] + } else if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + } + + for attempt := 1; ; attempt++ { + code, err := p.asker.Ask(question, false) + if err != nil { + return err + } + code = strings.TrimSpace(code) + if phone == nil && strings.EqualFold(code, "sms") { + if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + + continue + } + + route := "trusteddevice" + body := map[string]any{"securityCode": map[string]any{"code": code}} + if phone != nil { + route = "phone" + body["phoneNumber"] = map[string]any{"id": phone.ID} + body["mode"] = phoneMode(phone) + } + response, payload, err := p.request(ctx, http.MethodPost, + p.hosts.Idmsa+"/appleauth/auth/verify/"+route+"/securitycode", body, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode < 300 { + break + } + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if failure.has("-21669") || strings.Contains(strings.ToLower(failure.message()), "verification code") { + if attempt >= codeAttempts { + return errors.New("the verification code was wrong too many times") + } + p.log("That code is not right. Try again.") + + continue + } + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the verification code: %s", message) + } + + return fmt.Errorf("Apple refused the verification code (HTTP %d)", response.StatusCode) + } + + // Trusting the session is what lets the next run skip two-factor. A + // failure here still leaves a working session, as spaceship notes. + _, _, _ = p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth/2sv/trust", nil, p.idmsaHeaders()) + + return nil +} + +func phoneMode(phone *trustedPhone) string { + if phone.PushMode != "" { + return phone.PushMode + } + + return "sms" +} + +// textCode asks which trusted phone should get the code and has Apple text it. +func (p *portalClient) textCode(ctx context.Context, phones []trustedPhone) (*trustedPhone, error) { + chosen := 0 + if len(phones) > 1 { + labels := make([]string, len(phones)) + for index, phone := range phones { + labels[index] = phone.NumberWithDialCode + } + var err error + if chosen, err = p.asker.Choose("Which phone number should get the code?", labels); err != nil { + return nil, err + } + } + phone := &phones[chosen] + response, payload, err := p.request(ctx, http.MethodPut, p.hosts.Idmsa+"/appleauth/auth/verify/phone", map[string]any{ + "phoneNumber": map[string]any{"id": phone.ID}, + "mode": phoneMode(phone), + }, p.idmsaHeaders()) + if err != nil { + return nil, fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode >= 300 { + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return nil, fmt.Errorf("Apple could not text the code: %s", message) + } + + return nil, fmt.Errorf("Apple could not text the code (HTTP %d)", response.StatusCode) + } + p.log("Apple sent a code to %s.", phone.NumberWithDialCode) + + return phone, nil +} diff --git a/templates/cli/internal/apns/appwrite/jar.go b/templates/cli/internal/apns/appwrite/jar.go new file mode 100644 index 0000000000..8c60dda5ac --- /dev/null +++ b/templates/cli/internal/apns/appwrite/jar.go @@ -0,0 +1,129 @@ +package appwrite + +import ( + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "sync" + "time" +) + +// savedJar is a cookie jar that can be written to disk, so a signed-in and +// trusted Apple session is reused and the next run skips two-factor. +// net/http/cookiejar cannot list its cookies, so each cookie is also kept with +// the URL it came from and replayed into a fresh jar when loaded. +type savedJar struct { + mu sync.Mutex + inner *cookiejar.Jar + cookies map[string]savedCookie +} + +type savedCookie struct { + URL string `json:"url"` + Name string `json:"name"` + Value string `json:"value"` + Quoted bool `json:"quoted,omitempty"` + Domain string `json:"domain,omitempty"` + Path string `json:"path,omitempty"` + Expires time.Time `json:"expires,omitzero"` + Secure bool `json:"secure,omitempty"` + HTTPOnly bool `json:"httpOnly,omitempty"` +} + +func newSavedJar() *savedJar { + inner, _ := cookiejar.New(nil) + + return &savedJar{inner: inner, cookies: map[string]savedCookie{}} +} + +func (j *savedJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + j.inner.SetCookies(u, cookies) + origin := (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/"}).String() + for _, cookie := range cookies { + domain := cookie.Domain + if domain == "" { + domain = u.Hostname() + } + id := domain + "|" + cookie.Path + "|" + cookie.Name + expires := cookie.Expires + if cookie.MaxAge > 0 { + expires = time.Now().Add(time.Duration(cookie.MaxAge) * time.Second) + } + if cookie.MaxAge < 0 || (!expires.IsZero() && expires.Before(time.Now())) { + delete(j.cookies, id) + + continue + } + j.cookies[id] = savedCookie{ + URL: origin, Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: expires, + Secure: cookie.Secure, HTTPOnly: cookie.HttpOnly, + } + } +} + +func (j *savedJar) Cookies(u *url.URL) []*http.Cookie { + return j.inner.Cookies(u) +} + +// load replays the cookies saved at path. A missing or unreadable file leaves +// the jar empty: the caller then signs in again. +func (j *savedJar) load(path string) { + contents, err := os.ReadFile(path) + if err != nil { + return + } + var saved []savedCookie + if json.Unmarshal(contents, &saved) != nil { + return + } + for _, cookie := range saved { + origin, err := url.Parse(cookie.URL) + if err != nil || (!cookie.Expires.IsZero() && cookie.Expires.Before(time.Now())) { + continue + } + j.SetCookies(origin, []*http.Cookie{{ + Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: cookie.Expires, + Secure: cookie.Secure, HttpOnly: cookie.HTTPOnly, + }}) + } +} + +// save writes the cookies to path, readable by the user only. +func (j *savedJar) save(path string) error { + j.mu.Lock() + saved := make([]savedCookie, 0, len(j.cookies)) + for _, cookie := range j.cookies { + saved = append(saved, cookie) + } + j.mu.Unlock() + + contents, err := json.Marshal(saved) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".session-*") + if err != nil { + return err + } + defer os.Remove(temporary.Name()) + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + + return err + } + if err := temporary.Close(); err != nil { + return err + } + + return os.Rename(temporary.Name(), path) +} diff --git a/templates/cli/internal/apns/appwrite/portal.go b/templates/cli/internal/apns/appwrite/portal.go new file mode 100644 index 0000000000..0333fb73e1 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/portal.go @@ -0,0 +1,226 @@ +package appwrite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +// The Developer portal's private API, as the portal's own pages and +// @expo/apple-utils use it: cookie authenticated JSON under +// services-account/QH65B2, with the team in every request and CSRF tokens +// that a read hands out and a write sends back. + +const ( + apnsServiceID = "U27F4V844T" + keysPageSize = 500 +) + +var ( + errSessionExpired = errors.New("the Apple session expired") + errMaxKeys = errors.New("maximum keys") +) + +// Team is an Apple Developer team the account belongs to. +type Team struct { + ID string `json:"teamId"` + Name string `json:"name"` + Type string `json:"type"` +} + +// portalKey is a key as the portal lists it. +type portalKey struct { + ID string `json:"keyId"` + Name string `json:"keyName"` + CanRevoke bool `json:"canRevoke"` +} + +type portalResult struct { + ResultCode int `json:"resultCode"` + ResultString string `json:"resultString"` + UserString string `json:"userString"` +} + +func (r portalResult) message() string { + for _, value := range []string{r.UserString, r.ResultString} { + if decoded, err := url.PathUnescape(value); err == nil && decoded != "" { + return decoded + } + if value != "" { + return value + } + } + + return "" +} + +// portal calls one endpoint and returns the raw body after checking the +// result code. GET requests carry teamID in the query, others in the body. +func (p *portalClient) portal(ctx context.Context, method, path, teamID string, values map[string]any) ([]byte, error) { + target := p.hosts.Portal + "/services-account/QH65B2/" + path + var body any + if method == http.MethodGet { + query := url.Values{} + if teamID != "" { + query.Set("teamId", teamID) + } + for name, value := range values { + query.Set(name, fmt.Sprint(value)) + } + target += "?" + query.Encode() + } else { + data := map[string]any{} + if teamID != "" { + data["teamId"] = teamID + } + for name, value := range values { + data[name] = value + } + body = data + } + headers := map[string]string{"Accept": "application/json, text/plain, */*"} + if p.csrf != "" || p.csrfTS != "" { + headers["csrf"] = p.csrf + headers["csrf_ts"] = p.csrfTS + } + + response, payload, err := p.request(ctx, method, target, body, 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 || response.StatusCode == http.StatusForbidden { + return nil, errSessionExpired + } + if response.StatusCode >= 300 { + return nil, fmt.Errorf("the Apple Developer portal returned HTTP %d for %s", response.StatusCode, path) + } + + return payload, nil +} + +// result decodes a JSON portal response, failing on a non-zero result code. +func result(payload []byte, out any) error { + var status portalResult + if err := json.Unmarshal(payload, &status); err != nil { + return fmt.Errorf("the Apple Developer portal sent an unreadable response: %w", err) + } + if status.ResultCode != 0 { + message := status.message() + if strings.Contains(message, "session has expired") { + return errSessionExpired + } + if strings.Contains(message, "maximum allowed number of Keys") { + return errMaxKeys + } + if message == "" { + message = fmt.Sprintf("result code %d", status.ResultCode) + } + + return fmt.Errorf("the Apple Developer portal refused the request: %s", message) + } + + return json.Unmarshal(payload, out) +} + +func (p *portalClient) teams(ctx context.Context) ([]Team, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/listTeams.action", "", nil) + if err != nil { + return nil, err + } + var response struct { + Teams []Team `json:"teams"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + + return response.Teams, nil +} + +// keys lists the team's keys. It also hands out the CSRF tokens that creating +// a key needs, so it always runs first. +func (p *portalClient) keys(ctx context.Context, teamID string) ([]apns.ExistingKey, error) { + var all []apns.ExistingKey + for page := 1; ; page++ { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/list", teamID, map[string]any{ + "pageNumber": page, + "pageSize": keysPageSize, + "sort": "name=asc", + }) + if err != nil { + return nil, err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + for _, key := range response.Keys { + all = append(all, apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke}) + } + if len(response.Keys) < keysPageSize { + return all, nil + } + } +} + +// createKey creates a team-scoped key with only APNs enabled, for the given +// environment, and returns its ID. +func (p *portalClient) createKey(ctx context.Context, teamID, name string, environment apns.Environment) (string, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/v2/create", teamID, map[string]any{ + "name": name, + "scope": "team", + "serviceConfigurations": map[string][]string{apnsServiceID: {}}, + "serviceConfigurationsRequests": []map[string]any{{ + "isNew": true, + "serviceId": apnsServiceID, + "identifiers": map[string]any{}, + "environment": string(environment), + "scope": "team", + }}, + }) + if err != nil { + return "", err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return "", err + } + if len(response.Keys) == 0 || response.Keys[0].ID == "" { + return "", errors.New("the Apple Developer portal created no key") + } + + return response.Keys[0].ID, nil +} + +// downloadKey returns the key's .p8 contents. Apple serves them once. +func (p *portalClient) downloadKey(ctx context.Context, teamID, keyID string) (string, error) { + payload, err := p.portal(ctx, http.MethodGet, "account/auth/key/download", teamID, map[string]any{"keyId": keyID}) + if err != nil { + return "", err + } + contents := string(payload) + if !strings.Contains(contents, "PRIVATE KEY") { + var ignored struct{} + if err := result(payload, &ignored); err != nil { + return "", err + } + + return "", errors.New("the Apple Developer portal did not send the key") + } + + return contents, nil +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/srp.go b/templates/cli/internal/apns/appwrite/srp.go new file mode 100644 index 0000000000..659004b4b3 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp.go @@ -0,0 +1,174 @@ +package appwrite + +import ( + "crypto/pbkdf2" + "crypto/sha1" + "crypto/sha256" + "errors" + "fmt" + "io" + "math/big" + "strconv" + "strings" + "time" +) + +// Apple signs in with SRP-6a over the 2048-bit group of RFC 5054 and SHA-256, +// so the password itself never leaves the machine. This follows +// fastlane-sirp, the SRP client spaceship uses, byte for byte. Its quirks +// matter: x leaves the username out, and A, S and the server proof are +// hashed and sent without leading zero bytes, while k and u pad their inputs +// to the length of N. + +var ( + srpN = mustHex(strings.Join([]string{ + "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050", + "A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50", + "E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8", + "55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B", + "CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748", + "544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6", + "AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6", + "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73", + }, "")) + srpG = big.NewInt(2) + srpLength = len(srpN.Bytes()) +) + +func mustHex(value string) *big.Int { + number, ok := new(big.Int).SetString(value, 16) + if !ok { + panic("invalid hex: " + value) + } + + return number +} + +func sha256Of(parts ...[]byte) []byte { + hash := sha256.New() + for _, part := range parts { + hash.Write(part) + } + + return hash.Sum(nil) +} + +// padded returns value left-padded with zeros to the length of N. +func padded(value []byte) []byte { + if len(value) >= srpLength { + return value + } + out := make([]byte, srpLength) + copy(out[srpLength-len(value):], value) + + return out +} + +// hashPadded is fastlane-sirp's H(): the hash of the padded values, as a +// number reduced modulo N. +func hashPadded(values ...[]byte) *big.Int { + parts := make([][]byte, len(values)) + for index, value := range values { + parts[index] = padded(value) + } + sum := new(big.Int).SetBytes(sha256Of(parts...)) + + return sum.Mod(sum, srpN) +} + +// passwordKey derives the value Apple's SRP uses as the password: PBKDF2 over +// the SHA-256 of the password, hex encoded first for the older s2k_fo protocol. +func passwordKey(password string, salt []byte, iterations int, protocol string) ([]byte, error) { + digest := sha256Of([]byte(password)) + switch protocol { + case "s2k": + case "s2k_fo": + digest = []byte(fmt.Sprintf("%x", digest)) + default: + return nil, fmt.Errorf("unsupported Apple sign-in protocol %q", protocol) + } + + return pbkdf2.Key(sha256.New, string(digest), salt, iterations, 32) +} + +type srpClient struct { + a *big.Int + A []byte +} + +func newSRPClient(random io.Reader) (*srpClient, error) { + secret := make([]byte, 256) + if _, err := io.ReadFull(random, secret); err != nil { + return nil, err + } + + return newSRPClientWith(new(big.Int).SetBytes(secret)), nil +} + +func newSRPClientWith(a *big.Int) *srpClient { + return &srpClient{a: a, A: new(big.Int).Exp(srpG, a, srpN).Bytes()} +} + +// proofs computes the client proof M1 and the expected server proof M2 for +// the server's salt and B, given the derived password key. +func (c *srpClient) proofs(accountName string, key, salt, serverB []byte) ([]byte, []byte, error) { + B := new(big.Int).SetBytes(serverB) + if new(big.Int).Mod(B, srpN).Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + u := hashPadded(c.A, serverB) + if u.Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + k := hashPadded(srpN.Bytes(), srpG.Bytes()) + x := new(big.Int).SetBytes(sha256Of(salt, sha256Of([]byte(":"), key))) + + // S = (B - k * g^x) ^ (a + u * x) mod N + base := new(big.Int).Exp(srpG, x, srpN) + base.Mul(base, k) + base.Sub(B, base) + base.Mod(base, srpN) + exponent := new(big.Int).Mul(u, x) + exponent.Add(exponent, c.a) + S := new(big.Int).Exp(base, exponent, srpN) + K := sha256Of(S.Bytes()) + + hashN := hashPadded(srpN.Bytes()) + hashG := hashPadded(srpG.Bytes()) + xor := new(big.Int).Xor(hashN, hashG) + M1 := sha256Of(xor.Bytes(), sha256Of([]byte(accountName)), salt, c.A, serverB, K) + M2 := new(big.Int).SetBytes(sha256Of(c.A, M1, K)).Bytes() + + return M1, M2, nil +} + +// hashcash answers Apple's proof-of-work challenge: the first counter whose +// "1:bits:date:challenge::counter" string has a SHA-1 starting with bits +// zero bits. +func hashcash(bits int, challenge string, now time.Time) string { + prefix := "1:" + strconv.Itoa(bits) + ":" + now.Format("20060102150405") + ":" + challenge + "::" + for counter := 0; ; counter++ { + token := prefix + strconv.Itoa(counter) + if leadingZeroBits(sha1.Sum([]byte(token))) >= bits { + return token + } + } +} + +func leadingZeroBits(sum [sha1.Size]byte) int { + count := 0 + for _, value := range sum { + if value == 0 { + count += 8 + + continue + } + for mask := byte(0x80); mask != 0 && value&mask == 0; mask >>= 1 { + count++ + } + + break + } + + return count +} diff --git a/templates/cli/internal/apns/appwrite/srp_test.go b/templates/cli/internal/apns/appwrite/srp_test.go new file mode 100644 index 0000000000..3e96538427 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp_test.go @@ -0,0 +1,109 @@ +package appwrite + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "math/big" + "testing" + "time" +) + +// The expected values were computed with fastlane-sirp and spaceship's +// hashcash, the Ruby code spaceship signs in to Apple with, for fixed inputs. +var srpVectors = []struct { + protocol, password string + iterations int + a, clientA, salt, serverB, derived, m1, m2 string +}{ + { + protocol: "s2k", + password: "correct horse battery staple", + iterations: 1000, + a: "976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c", + clientA: "lOFBKFUcFnIqiPacVzlN2auN4I/AMJTWac7+/T1qEp2nhqIlgqfiFMgS/lLgf2YHa/88G9823FwFSNcRw/h46ujunwE/pkwZKFscpaa68psSBryNQFK1HsWrHblsQdOicXq+dmD+p70d1tx6w935hE8Htq+3iFdyqvb/nrau/qdyKJ1ueEWpnJ71r35XmHdfVQ94/qAsOp753gLCRFNdTmvFONzkqS9PRzC3Ug9F0PKeMf1kQhurzoMZRo/HqS+Pz7bTdDW5efKMg4s6uvX7t+LffwUwxuoCP/NksHQyRznffQivjnh596NJ4XCrEpvu+tE4bOBjtlSYSYI45JQmrw==", + salt: "8XJskbjIRdqLv44iq8fuiQ==", + serverB: "bmqruv0a/EiZ7xJfeLeG1ilXyXzhUJNYtDmwpbgK152y+6RctWvwBHLLnTVQlfHYzKIgMrlChKSyCl6tXjOkKz73r03bRCUL675sZDN7K3pGEL2TvrU/qdb7XBgKUWI1ctW54xynuBy1YpSYoETwrrHirVB3LDKlBdp+J3T+MfuJeR270cGJ/nDKAXtfj3qwagSrM17wI193qe4jEevZD4mvlKztv29zQ6/T9q6vTopJ524kZTzcY00t0y9r7jVFzpRXXMfg5hCzrNdKkECjFpC6/UI/xDKmyl/Uux8yG5dA5Gcxcsb0GfT7g01i3sdd619gUlceFifCpvI56NIapQ==", + derived: "adb53b51adb7d706507d630b73e0d85ec46c34ffa95eabc6bfb6893092bfe538", + m1: "T02D0QeodD+9ldyfjZQUTynd976L9gg52UBkxEFXRjE=", + m2: "bkDJQIvXJOIj1/cMlHJfnab9VrMyBSzaVNJY4kdCik0=", + }, + { + protocol: "s2k_fo", + password: "p@ss wörd", + iterations: 1001, + a: "62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b", + clientA: "q3Y06Eo7PTQ8TvzRw5xZqRwCKcw0GTxg5+6b5a75N+Dy3rnNXSAM0/aPomhpAcHMz14FtSALPmpNXEYa3UxUUGWiLkp1RDLTzdJT2U/+X0lU6XBm5W0O8hyYe3/Na8JrQvNvsDfmahk4/w8ZKKDTSq1cbRiQHWV7wA/D5tl5jh/xPADahOe4jt3rU+COrHR3JcVDWknM0B4cL0HywA2J2/RrK/ushhR/E8Uu7wGvb7LbcFNhOmp4q6q9bLrm5zS6tbH6dXfD3V5W4bvgFOwhKKmS0B39v4sZZRSkHiTdLMAipBgShP5kPAi1S0RoRXFYD1sHI2v7m0mP5P+S45wI", + salt: "lneDgZnxLTb17pcdSfaKvA==", + serverB: "Ht8tUspsId2o6YnOxb/QKSLQ3vn0scNeYsV7ElRHRmXKj3b6neErfO7BCpQFvY59g1hvwwDT2fFa4Ah2nzLjRHG83W/vA/Jt0MltHSECjhV0ASfMHfMQVA6VlDLNAdAO8e4weK49v9j/AWobmdXQjBkSRTuTzMT4rKjXlmXdT54Mo3ryCPxhA2r4u+FYV4qn2vaW2GBKAj2ONMZQW0DWV90LhnnQZ6mib/mpUctOv+rTZ893ZbtytYByZtwnd91AV1QV55KNOmEqRrJEXlb3oqMVLR0FQyai2kZVH5yIT6Jri/wTfKKNkl1vIYsbM5ctc0k7UdADQYQ8070Bd1k0mQ==", + derived: "296173526d4ff2ba32b6027b25166623f12566dad89b707af650e146628e95b4", + m1: "Gfm875AAWz5Rh4ok/WHAzVuVqKPG6jwLnf/5OGyl+H4=", + m2: "XxfUcq4O5mBX/SC9s3QCv8nU0fuz5R47opb5rO9JDzE=", + }, +} + +func decode(t *testing.T, value string) []byte { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatal(err) + } + + return decoded +} + +func TestSRPMatchesFastlane(t *testing.T) { + for _, vector := range srpVectors { + t.Run(vector.protocol, func(t *testing.T) { + salt := decode(t, vector.salt) + key, err := passwordKey(vector.password, salt, vector.iterations, vector.protocol) + if err != nil || hex.EncodeToString(key) != vector.derived { + t.Fatalf("password key = %x, %v", key, err) + } + + a, _ := new(big.Int).SetString(vector.a, 16) + client := newSRPClientWith(a) + if !bytes.Equal(client.A, decode(t, vector.clientA)) { + t.Errorf("A = %s", base64.StdEncoding.EncodeToString(client.A)) + } + m1, m2, err := client.proofs("dev@example.com", key, salt, decode(t, vector.serverB)) + if err != nil { + t.Fatal(err) + } + if got := base64.StdEncoding.EncodeToString(m1); got != vector.m1 { + t.Errorf("m1 = %s, want %s", got, vector.m1) + } + if got := base64.StdEncoding.EncodeToString(m2); got != vector.m2 { + t.Errorf("m2 = %s, want %s", got, vector.m2) + } + }) + } +} + +func TestSRPRejectsAnInvalidChallenge(t *testing.T) { + client := newSRPClientWith(big.NewInt(12345)) + if _, _, err := client.proofs("dev@example.com", []byte("key"), []byte("salt"), srpN.Bytes()); err == nil { + t.Error("accepted B = N") + } + if _, err := passwordKey("password", []byte("salt"), 1, "s2k_unknown"); err == nil { + t.Error("accepted an unknown protocol") + } +} + +func TestHashcashMatchesSpaceship(t *testing.T) { + for _, vector := range []struct { + bits int + challenge, date, token string + }{ + {11, "4d74fb15eb23f465f1f6fcbf534e5877", "20230223170600", "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373"}, + {10, "abc", "20261009120000", "1:10:20261009120000:abc::1266"}, + } { + date, err := time.Parse("20060102150405", vector.date) + if err != nil { + t.Fatal(err) + } + if got := hashcash(vector.bits, vector.challenge, date); got != vector.token { + t.Errorf("hashcash(%d, %s) = %s, want %s", vector.bits, vector.challenge, got, vector.token) + } + } +} From a02c29bfa0727d61b6ac0f710ad98a1ed67dfe65 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:55:21 +0530 Subject: [PATCH 07/29] feat(cli): offer the appwrite APNs key setup in init apns Registers the appwrite adapter, the default for --create-key, so the Apple sign-in is offered and --provider appwrite (or APPWRITE_APNS_SETUP=appwrite) selects it. --- templates/cli/internal/cmd/initpush.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 197c0b4d49..11e7ab2ba1 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -23,6 +23,7 @@ import ( "time" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/appwrite" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" @@ -565,7 +566,7 @@ const ( // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { - return apns.NewRegistry() + return apns.NewRegistry(appwrite.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then From 1020be5d5ed3a01d2b168eb29387f603927c920e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:58:20 +0530 Subject: [PATCH 08/29] feat(cli): add the expo APNs key setup on @expo/apple-utils The expo adapter creates APNs keys with @expo/apple-utils 2.2.1 (MIT), the library eas-cli uses. A small Node script, its package.json and a lockfile pinning the package hash are embedded with go:embed and installed into the session folder with npm ci --ignore-scripts when they change. It needs Node.js 18 or later and reports apns.ErrUnavailable otherwise, before contacting Apple. apple-utils prompts on the terminal for the two-factor code and for anything the request leaves out; the request's Apple ID and password are passed as EXPO_APPLE_ID and EXPO_APPLE_PASSWORD, and EXPO_NO_KEYCHAIN=1 keeps the password out of the macOS Keychain. Its InvalidUserCredentialsError maps to apns.ErrInvalidCredentials and a full team to apns.MaxKeysError. apple-utils always creates keys for both environments, which also serve a single-environment request. --- src/SDK/Language/CLI.php | 25 ++ templates/cli/internal/apns/expo/adapter.go | 251 ++++++++++++++++++ .../cli/internal/apns/expo/adapter_test.go | 159 +++++++++++ .../cli/internal/apns/expo/helper/helper.js | 77 ++++++ .../apns/expo/helper/package-lock.json | 25 ++ .../internal/apns/expo/helper/package.json | 11 + 6 files changed, 548 insertions(+) create mode 100644 templates/cli/internal/apns/expo/adapter.go create mode 100644 templates/cli/internal/apns/expo/adapter_test.go create mode 100644 templates/cli/internal/apns/expo/helper/helper.js create mode 100644 templates/cli/internal/apns/expo/helper/package-lock.json create mode 100644 templates/cli/internal/apns/expo/helper/package.json diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 946e66a73e..1a4785efa6 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1161,6 +1161,31 @@ public function getFiles(): array 'destination' => 'internal/apns/apnstest/apnstest.go', 'template' => 'cli/internal/apns/apnstest/apnstest.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter.go', + 'template' => 'cli/internal/apns/expo/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter_test.go', + 'template' => 'cli/internal/apns/expo/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/helper.js', + 'template' => 'cli/internal/apns/expo/helper/helper.js', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package.json', + 'template' => 'cli/internal/apns/expo/helper/package.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package-lock.json', + 'template' => 'cli/internal/apns/expo/helper/package-lock.json', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go new file mode 100644 index 0000000000..86eef419c7 --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter.go @@ -0,0 +1,251 @@ +// Package expo is the APNs key setup built on @expo/apple-utils, the library +// eas-cli creates APNs keys with. +// +// It runs a small Node script embedded in the binary, with its package.json +// and a lockfile that pins @expo/apple-utils 2.2.1 (MIT) by hash. They are +// installed into the session folder on first use with npm ci +// --ignore-scripts. It needs Node.js 18 or later. +package expo + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/helper.js helper/package.json helper/package-lock.json +var helperFiles embed.FS + +const minimumNodeMajor = 18 + +// Adapter creates APNs keys with @expo/apple-utils. +type Adapter struct { + // Node and Npm are the commands to run. Empty means "node" and "npm". + Node string + Npm string + // apple-utils prompts for anything the request does not answer, and for + // the two-factor code, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using node and npm from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "expo" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` +} + +// 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 err := a.checkNode(ctx); err != nil { + return apns.Key{}, err + } + dir := request.SessionDir + if dir == "" { + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + dir = scratch + } + if err := a.install(ctx, dir); err != nil { + return apns.Key{}, err + } + 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) + } + + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") + if err != nil { + return apns.Key{}, 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) + } + command := exec.CommandContext(ctx, a.node(), arguments...) + command.Dir = dir + command.Stdin, command.Stdout, command.Stderr = a.terminal() + // apple-utils reads the Apple ID and password from these instead of + // asking, and stores the password in the macOS Keychain unless told not + // to. The CLI never keeps the password. + command.Env = append(os.Environ(), "EXPO_NO_KEYCHAIN=1") + if request.AppleID != "" { + command.Env = append(command.Env, "EXPO_APPLE_ID="+request.AppleID) + } + if request.Password != "" { + command.Env = append(command.Env, "EXPO_APPLE_PASSWORD="+request.Password) + } + runErr := command.Run() + + 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 apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) + } + + return decodeResult(contents) +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + 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) + } + if decoded.KeyID == "" || decoded.P8 == "" { + 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 +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +func (a *Adapter) node() string { + if a.Node != "" { + return a.Node + } + + return "node" +} + +func (a *Adapter) npm() string { + if a.Npm != "" { + return a.Npm + } + + return "npm" +} + +var nodeVersion = regexp.MustCompile(`^v?(\d+)\.`) + +func (a *Adapter) checkNode(ctx context.Context) error { + output, err := exec.CommandContext(ctx, a.node(), "--version").Output() + if err != nil { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later", apns.ErrUnavailable, minimumNodeMajor) + } + + return supportedNode(string(bytes.TrimSpace(output))) +} + +func supportedNode(version string) error { + match := nodeVersion.FindStringSubmatch(version) + if match == nil { + return fmt.Errorf("%w: could not read the Node.js version %q", apns.ErrUnavailable, version) + } + if major, _ := strconv.Atoi(match[1]); major < minimumNodeMajor { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later, found %s", apns.ErrUnavailable, minimumNodeMajor, version) + } + + return nil +} + +// install writes the embedded files into dir and runs npm ci when the +// lockfile changed or the dependencies are missing. The lockfile pins the +// exact package hashes, and --ignore-scripts keeps install scripts from +// running. +func (a *Adapter) install(ctx context.Context, dir string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + changed := false + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + contents, err := helperFiles.ReadFile("helper/" + name) + if err != nil { + return err + } + path := filepath.Join(dir, name) + if current, err := os.ReadFile(path); err == nil && bytes.Equal(current, contents) { + continue + } + if err := os.WriteFile(path, contents, 0o600); err != nil { + return err + } + changed = true + } + + if _, err := os.Stat(filepath.Join(dir, "node_modules", "@expo", "apple-utils", "package.json")); err == nil && !changed { + return nil + } + _, _, stderr := a.terminal() + command := exec.CommandContext(ctx, a.npm(), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + command.Dir = dir + command.Stdout, command.Stderr = stderr, stderr + if err := command.Run(); err != nil { + // A failed install must not look complete on the next run. + _ = os.Remove(filepath.Join(dir, "package-lock.json")) + + return fmt.Errorf("%w: npm ci failed in %s: %v", apns.ErrUnavailable, dir, err) + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go new file mode 100644 index 0000000000..c03ca3986b --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -0,0 +1,159 @@ +package expo + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedNode(t *testing.T) { + for version, supported := range map[string]bool{ + "v18.0.0": true, + "v22.11.0": true, + "v16.20.2": false, + "v8.9.0": false, + "garbage": false, + } { + err := supportedNode(version) + if (err == nil) != supported { + t.Errorf("supportedNode(%q) = %v", version, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedNode(%q) is not ErrUnavailable: %v", version, err) + } + } +} + +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" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || maxKeys.Keys[0].Name != "Old" { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeTools writes node and npm stand-ins that log their arguments. The node +// stand-in answers --version and writes result to the --out file. +func fakeTools(t *testing.T, version, result string) (string, string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "` + version + `"; exit 0; fi +echo "node $* EXPO_NO_KEYCHAIN=$EXPO_NO_KEYCHAIN EXPO_APPLE_ID=$EXPO_APPLE_ID" >> "` + log + `" +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '` + result + `' > "$2"; fi + shift +done +` + npm := `#!/bin/sh +echo "npm $*" >> "` + log + `" +mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json +` + for name, script := range map[string]string{"node": node, "npm": npm} { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + + return filepath.Join(bin, "node"), filepath.Join(bin, "npm"), log +} + +func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentAll, + AppleID: "dev@example.com", + SessionDir: filepath.Join(t.TempDir(), "expo"), + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + } + + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[0], "npm ci --ignore-scripts") { + 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") { + t.Errorf("helper call = %s", line) + } + } + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + if _, err := os.Stat(filepath.Join(request.SessionDir, name)); err != nil { + t.Errorf("%s was not installed: %v", name, err) + } + } +} + +func TestCreateKeyNotesTheEnvironmentItCannotRestrict(t *testing.T) { + node, npm, _ := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if len(logged) != 1 || !strings.Contains(logged[0], "both APNs environments") { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyNeedsARecentNode(t *testing.T) { + node, npm, log := fakeTools(t, "v16.20.2", `{}`) + adapter := &Adapter{Node: node, Npm: npm} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir()} + + if _, err := adapter.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran npm or the helper with an old Node.js") + } + + missing := &Adapter{Node: filepath.Join(t.TempDir(), "node")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing node err = %v", err) + } +} diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js new file mode 100644 index 0000000000..339db2a27a --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -0,0 +1,77 @@ +'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. +// +// 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'); + +function parseArguments(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + values[argv[index].replace(/^--/, '')] = argv[index + 1]; + } + if (!values.out || !values.name) { + throw new Error('usage: helper.js [--team-id ] --name --out '); + } + + return values; +} + +function isMaxKeysError(error) { + return ( + error instanceof Keys.MaxKeysCreatedError || + /maximum allowed number of Keys/.test(error?.rawDump?.resultString ?? '') + ); +} + +async function createKey({ 'team-id': teamId, name }) { + // Without a team ID, apple-utils asks which team to use when there are several. + const 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}`); + } + + let key; + try { + key = await Keys.createKeyAsync(context, { name, isApns: true }); + } catch (error) { + if (!isMaxKeysError(error)) { + throw error; + } + const keys = await Keys.getKeysAsync(context); + + return { + error: 'max-keys', + keys: keys.map(({ id, name, canRevoke }) => ({ id, name, canRevoke })), + }; + } + const p8 = await Keys.downloadKeyAsync(context, { id: key.id }); + + return { keyId: key.id, teamId: context.teamId, p8 }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + let result; + try { + result = await createKey(options); + } catch (error) { + const kind = error instanceof InvalidUserCredentialsError ? 'invalid-credentials' : 'failed'; + result = { error: kind, message: error?.message ?? String(error) }; + } + fs.writeFileSync(options.out, JSON.stringify(result), { mode: 0o600 }); + if (result.error) { + process.exitCode = 1; + } +} + +main().catch(error => { + console.error(error?.message ?? error); + process.exitCode = 1; +}); diff --git a/templates/cli/internal/apns/expo/helper/package-lock.json b/templates/cli/internal/apns/expo/helper/package-lock.json new file mode 100644 index 0000000000..cd61fd039f --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "appwrite-apple-helper", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appwrite-apple-helper", + "dependencies": { + "@expo/apple-utils": "2.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@expo/apple-utils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@expo/apple-utils/-/apple-utils-2.2.1.tgz", + "integrity": "sha512-Jr0yawuiJ83Lu76krKJYysXfvSC678YK8nIBHUoNjrOAQT392LttbfsoPA+8/zDBZOrfo+8eXgBq0a51b7wBVQ==", + "license": "MIT", + "bin": { + "apple-utils": "bin.js" + } + } + } +} diff --git a/templates/cli/internal/apns/expo/helper/package.json b/templates/cli/internal/apns/expo/helper/package.json new file mode 100644 index 0000000000..2e4c5e4598 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package.json @@ -0,0 +1,11 @@ +{ + "name": "appwrite-apple-helper", + "private": true, + "description": "Creates an APNs key through the Apple Developer portal for appwrite init apns --create-key", + "engines": { + "node": ">=18" + }, + "dependencies": { + "@expo/apple-utils": "2.2.1" + } +} From 85da856e9bde229363b5e8ed5b1593f485e83829 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:58:20 +0530 Subject: [PATCH 09/29] feat(cli): offer the expo APNs key setup in init apns Registers the expo adapter, picked with --provider expo or APPWRITE_APNS_SETUP=expo. --- templates/cli/internal/cmd/initpush.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 197c0b4d49..81fdddf73d 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -23,6 +23,7 @@ import ( "time" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/expo" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" @@ -565,7 +566,7 @@ const ( // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { - return apns.NewRegistry() + return apns.NewRegistry(expo.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then From f9692f7bccd349160170b929ad65477b75b552db Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:01:32 +0530 Subject: [PATCH 10/29] feat(cli): add the fastlane APNs key setup on spaceship The fastlane adapter creates APNs keys with spaceship, fastlane's open-source Apple Developer portal client, through the fastlane the person already has installed: a lane in an embedded Fastfile runs in a private temp folder, so a gem, Homebrew or bundler installation all work and no Ruby ships with the CLI. It needs fastlane 2.226.0 or later, the first to sign in with SRP, and reports apns.ErrUnavailable otherwise. The adapter asks for the Apple ID and password when the request has none and passes them as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane never offers to store them in the Keychain; spaceship asks for the two-factor code on the terminal. The lane calls create_key! directly so the requested environment is honoured (Key.create always asks for all), maps InvalidUserCredentialsError to apns.ErrInvalidCredentials and a full team to apns.MaxKeysError with the team's keys. --- src/SDK/Language/CLI.php | 15 ++ .../cli/internal/apns/fastlane/adapter.go | 225 ++++++++++++++++++ .../internal/apns/fastlane/adapter_test.go | 166 +++++++++++++ .../internal/apns/fastlane/helper/Fastfile | 54 +++++ 4 files changed, 460 insertions(+) create mode 100644 templates/cli/internal/apns/fastlane/adapter.go create mode 100644 templates/cli/internal/apns/fastlane/adapter_test.go create mode 100644 templates/cli/internal/apns/fastlane/helper/Fastfile diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 946e66a73e..fb86987483 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1161,6 +1161,21 @@ public function getFiles(): array 'destination' => 'internal/apns/apnstest/apnstest.go', 'template' => 'cli/internal/apns/apnstest/apnstest.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter.go', + 'template' => 'cli/internal/apns/fastlane/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter_test.go', + 'template' => 'cli/internal/apns/fastlane/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/fastlane/helper/Fastfile', + 'template' => 'cli/internal/apns/fastlane/helper/Fastfile', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go new file mode 100644 index 0000000000..dde7ebe681 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -0,0 +1,225 @@ +// Package fastlane is the APNs key setup built on fastlane's spaceship, the +// open-source Apple Developer portal client. +// +// It runs a lane from a Fastfile embedded in the binary with the fastlane the +// person already has installed, so it works with a gem, Homebrew or bundler +// installation and brings no Ruby of its own. spaceship signs in with SRP, +// since fastlane 2.226.0. +package fastlane + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/Fastfile +var fastfile []byte + +// The first fastlane that signs in with SRP, which Apple has required since +// October 2024. +var minimumVersion = [3]int{2, 226, 0} + +// Adapter creates APNs keys with an installed fastlane. +type Adapter struct { + // Fastlane is the command to run. Empty means "fastlane" from PATH. + Fastlane string + // spaceship prompts for the two-factor code, and for a team when the + // account has several, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using fastlane from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "fastlane" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` +} + +// 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. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + if err := a.checkFastlane(ctx); err != nil { + return apns.Key{}, err + } + + appleID := strings.TrimSpace(request.AppleID) + if appleID == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return apns.Key{}, err + } + appleID = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+appleID, true) + if err != nil { + return apns.Key{}, err + } + password = answer + } + + // fastlane runs the Fastfile in ./fastlane of its working directory. + scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { + return apns.Key{}, err + } + if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { + return apns.Key{}, err + } + out := filepath.Join(scratch, "result.json") + + arguments := []string{"create_apns_key", "out:" + out, "name:" + request.Name, "environment:" + string(request.Environment)} + if request.TeamID != "" { + arguments = append(arguments, "team_id:"+request.TeamID) + } + command := exec.CommandContext(ctx, a.fastlane(), arguments...) + command.Dir = scratch + command.Stdin, command.Stdout, command.Stderr = a.terminal() + command.Env = append(os.Environ(), + "FASTLANE_USER="+appleID, + "FASTLANE_PASSWORD="+password, + "FASTLANE_SKIP_UPDATE_CHECK=1", + "FASTLANE_OPT_OUT_USAGE=1", + "FASTLANE_HIDE_CHANGELOG=1", + ) + 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 apns.Key{}, fmt.Errorf("the fastlane lane returned no result: %w", err) + } + + return decodeResult(contents) +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + 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) + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the fastlane lane returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +func (a *Adapter) fastlane() string { + if a.Fastlane != "" { + return a.Fastlane + } + + return "fastlane" +} + +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + +func (a *Adapter) checkFastlane(ctx context.Context) error { + command := exec.CommandContext(ctx, a.fastlane(), "--version") + command.Env = append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") + output, err := command.Output() + if err != nil { + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later installed (https://docs.fastlane.tools)", apns.ErrUnavailable, versionString(minimumVersion)) + } + + return supportedVersion(string(output)) +} + +func supportedVersion(output string) error { + match := versionPattern.FindStringSubmatch(output) + if match == nil { + return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) + } + var version [3]int + for index := range version { + version[index], _ = strconv.Atoi(match[index+1]) + } + for index := range version { + if version[index] != minimumVersion[index] { + if version[index] < minimumVersion[index] { + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Run fastlane update_fastlane", apns.ErrUnavailable, versionString(minimumVersion), versionString(version)) + } + + break + } + } + + return nil +} + +func versionString(version [3]int) string { + return fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]) +} diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go new file mode 100644 index 0000000000..66ed53b867 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -0,0 +1,166 @@ +package fastlane + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedVersion(t *testing.T) { + for output, supported := range map[string]bool{ + "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, + "fastlane 2.226.0": true, + "fastlane 3.0.0": true, + "fastlane 2.225.9": false, + "fastlane 1.300.0": false, + "something else": false, + } { + err := supportedVersion(output) + if (err == nil) != supported { + t.Errorf("supportedVersion(%q) = %v", output, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedVersion(%q) is not ErrUnavailable: %v", output, err) + } + } +} + +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" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || !maxKeys.Keys[0].CanRevoke { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeFastlane writes a fastlane stand-in that answers --version with version +// and, for the lane, logs its arguments and environment, checks the Fastfile +// is in place and writes result to the out: file. +func fakeFastlane(t *testing.T, version, result string) (string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then printf 'fastlane installation at path:\n/usr/local/bin/fastlane\nfastlane ` + version + `\n'; exit 0; fi +test -f fastlane/Fastfile || { echo "no Fastfile" >> "` + log + `"; exit 1; } +password=unset +[ -n "$FASTLANE_PASSWORD" ] && password=set +echo "fastlane $* FASTLANE_USER=$FASTLANE_USER password=$password" >> "` + log + `" +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '` + result + `' > "${argument#out:}" ;; + esac +done +` + path := filepath.Join(bin, "fastlane") + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return path, log +} + +type scriptedAsker struct { + answers map[string]string + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answer := range s.answers { + if strings.HasPrefix(question, prefix) { + return answer, nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +func TestCreateKeyRunsTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{answers: map[string]string{"Apple ID": "dev@example.com", "Password for": "secret"}} + + key, err := adapter.CreateKey(context.Background(), apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + Asker: asker, + }) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for dev@example.com" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + call := strings.TrimSpace(string(calls)) + if !strings.HasPrefix(call, "fastlane create_apns_key out:") || + !strings.HasSuffix(call, "name:Appwrite Push environment:sandbox team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("call = %s", call) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{} + + _, err := adapter.CreateKey(context.Background(), apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + }) + if err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } +} + +func TestCreateKeyNeedsARecentFastlane(t *testing.T) { + old, log := fakeFastlane(t, "2.220.0", `{}`) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &scriptedAsker{}} + + if _, err := (&Adapter{Fastlane: old}).CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) || + !strings.Contains(err.Error(), "found 2.220.0") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran the lane with an old fastlane") + } + + missing := &Adapter{Fastlane: filepath.Join(t.TempDir(), "fastlane")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing fastlane err = %v", err) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile new file mode 100644 index 0000000000..57ad078f83 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -0,0 +1,54 @@ +# 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. +# +# 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 +# and FASTLANE_PASSWORD. fastlane logs to stdout, so the result is written as +# JSON to the file named by out: instead. +require 'json' + +lane :create_apns_key do |options| + result = begin + create_apns_key_with_spaceship(options) + rescue StandardError => error + kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' + { error: kind, message: error.message } + end + File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } +end + +def create_apns_key_with_spaceship(options) + require 'spaceship' + + Spaceship::Portal.login(ENV.fetch('FASTLANE_USER', nil), ENV.fetch('FASTLANE_PASSWORD', nil)) + 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 + + # Key.create always asks for every environment, so the client is called + # directly with the one requested. + environment = options[:environment].to_s.empty? ? 'all' : options[:environment].to_s + begin + created = client.create_key!( + name: options[:name], + service_configs: { + Spaceship::Portal::Key::APNS_ID => { scope: 'team', environment: environment, identifiers: {} } + } + ) + rescue StandardError => error + raise unless error.message.include?('maximum allowed number of Keys') + + keys = client.list_keys.map do |key| + { id: key['keyId'], name: key['keyName'], canRevoke: key['canRevoke'] == true } + end + return { error: 'max-keys', keys: keys } + end + + key_id = created['keyId'] + { keyId: key_id, teamId: client.team_id, p8: client.download_key(id: key_id) } +end From 84a5d767845b25a805c1dc00c7ec247e67ee13c9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:01:32 +0530 Subject: [PATCH 11/29] feat(cli): offer the fastlane APNs key setup in init apns Registers the fastlane adapter, picked with --provider fastlane or APPWRITE_APNS_SETUP=fastlane. --- templates/cli/internal/cmd/initpush.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 197c0b4d49..f7d298d0ab 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -23,6 +23,7 @@ import ( "time" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/fastlane" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" @@ -565,7 +566,7 @@ const ( // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { - return apns.NewRegistry() + return apns.NewRegistry(fastlane.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then From b55c767b326733952b56d49d26836d69e834f93e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:02:40 +0530 Subject: [PATCH 12/29] fix(cli): require fastlane 2.225.0 for the fastlane APNs key setup 2.225.0 (October 2024) is the release that added SRP sign-in, per its notes: earlier versions get 503 from Apple. The minimum was set one release too high. --- templates/cli/internal/apns/fastlane/adapter.go | 11 ++++++----- templates/cli/internal/apns/fastlane/adapter_test.go | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index dde7ebe681..418b2c92be 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -3,8 +3,8 @@ // // It runs a lane from a Fastfile embedded in the binary with the fastlane the // person already has installed, so it works with a gem, Homebrew or bundler -// installation and brings no Ruby of its own. spaceship signs in with SRP, -// since fastlane 2.226.0. +// installation and brings no Ruby of its own. It needs fastlane 2.225.0 or +// later, the first release whose spaceship signs in with SRP. package fastlane import ( @@ -27,9 +27,10 @@ import ( //go:embed helper/Fastfile var fastfile []byte -// The first fastlane that signs in with SRP, which Apple has required since -// October 2024. -var minimumVersion = [3]int{2, 226, 0} +// fastlane 2.225.0 (October 2024) is the first that signs in with SRP, which +// Apple has required since then; earlier versions get "503 Service +// Temporarily Unavailable" when signing in. +var minimumVersion = [3]int{2, 225, 0} // Adapter creates APNs keys with an installed fastlane. type Adapter struct { diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 66ed53b867..82913dc2fc 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -15,9 +15,9 @@ import ( func TestSupportedVersion(t *testing.T) { for output, supported := range map[string]bool{ "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, - "fastlane 2.226.0": true, + "fastlane 2.225.0": true, "fastlane 3.0.0": true, - "fastlane 2.225.9": false, + "fastlane 2.224.9": false, "fastlane 1.300.0": false, "something else": false, } { From 4ff11f54ad5a7c979d379d8bee5a70fa2b5a318c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:08:11 +0530 Subject: [PATCH 13/29] fix(cli): do not count switching to a text message as a code attempt Typing sms to get the code by text message used one of the three attempts, leaving two for actual codes. Only codes Apple rejects count now. --- .../internal/apns/appwrite/adapter_test.go | 21 +++++++++++++++++++ templates/cli/internal/apns/appwrite/auth.go | 8 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index 9e95e0bb5a..77277842e3 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -44,6 +44,7 @@ type fakeApple struct { // generation names the current signed-in cookie; bumping it expires // every session handed out before. generation int + textsSent int teams []Team keys []apns.ExistingKey maxKeys bool @@ -186,6 +187,10 @@ func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, f.sessionCookie()) w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPut && r.URL.Path == "/appleauth/auth/verify/phone" && twoFactorSession: + f.textsSent++ + writeJSON(w, http.StatusOK, map[string]any{}) + case r.URL.Path == "/appleauth/auth/verify/phone/securitycode" && twoFactorSession: phone, _ := params["phoneNumber"].(map[string]any) code := params["securityCode"].(map[string]any)["code"] @@ -580,4 +585,20 @@ func TestCreateKeyForOneEnvironment(t *testing.T) { } } + +func TestChoosingATextMessageIsNotACodeAttempt(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{answers: map[string][]string{ + "Enter the 6-digit code shown": {"sms"}, + "Enter the 6-digit code sent to": {"000000", "111111", testCode}, + }} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatalf("the third code was not accepted: %v", err) + } + if fake.textsSent != 1 || len(asker.asked) != 4 { + t.Errorf("texts = %d, asked %v", fake.textsSent, asker.asked) + } +} {% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go index 971fa5c321..690f45df75 100644 --- a/templates/cli/internal/apns/appwrite/auth.go +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -389,7 +389,10 @@ func (p *portalClient) twoFactor(ctx context.Context) error { question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) } - for attempt := 1; ; attempt++ { + // Only codes Apple rejects count as attempts; switching to a text message + // does not. + wrong := 0 + for { code, err := p.asker.Ask(question, false) if err != nil { return err @@ -422,7 +425,8 @@ func (p *portalClient) twoFactor(ctx context.Context) error { var failure serviceErrors _ = json.Unmarshal(payload, &failure) if failure.has("-21669") || strings.Contains(strings.ToLower(failure.message()), "verification code") { - if attempt >= codeAttempts { + wrong++ + if wrong >= codeAttempts { return errors.New("the verification code was wrong too many times") } p.log("That code is not right. Try again.") From 4ee56d84e7d8bea9cf6b7d27021c6f3f906c40af Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:08:11 +0530 Subject: [PATCH 14/29] fix(cli): send the hashcash date in UTC The hashcash date was the machine's local time. @expo/apple-utils sends UTC (spaceship's local time only matches on a UTC machine), so a non-UTC machine produced a date hours away from the one Apple expects. --- templates/cli/internal/apns/appwrite/srp.go | 5 +++-- templates/cli/internal/apns/appwrite/srp_test.go | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/templates/cli/internal/apns/appwrite/srp.go b/templates/cli/internal/apns/appwrite/srp.go index 659004b4b3..c3450b1b99 100644 --- a/templates/cli/internal/apns/appwrite/srp.go +++ b/templates/cli/internal/apns/appwrite/srp.go @@ -144,9 +144,10 @@ func (c *srpClient) proofs(accountName string, key, salt, serverB []byte) ([]byt // hashcash answers Apple's proof-of-work challenge: the first counter whose // "1:bits:date:challenge::counter" string has a SHA-1 starting with bits -// zero bits. +// zero bits. The date is in UTC, as @expo/apple-utils sends it (spaceship +// uses local time, which only matches on a UTC machine). func hashcash(bits int, challenge string, now time.Time) string { - prefix := "1:" + strconv.Itoa(bits) + ":" + now.Format("20060102150405") + ":" + challenge + "::" + prefix := "1:" + strconv.Itoa(bits) + ":" + now.UTC().Format("20060102150405") + ":" + challenge + "::" for counter := 0; ; counter++ { token := prefix + strconv.Itoa(counter) if leadingZeroBits(sha1.Sum([]byte(token))) >= bits { diff --git a/templates/cli/internal/apns/appwrite/srp_test.go b/templates/cli/internal/apns/appwrite/srp_test.go index 3e96538427..91d17b7d0a 100644 --- a/templates/cli/internal/apns/appwrite/srp_test.go +++ b/templates/cli/internal/apns/appwrite/srp_test.go @@ -90,6 +90,14 @@ func TestSRPRejectsAnInvalidChallenge(t *testing.T) { } } +func TestHashcashUsesUTC(t *testing.T) { + utc := time.Date(2023, 2, 23, 17, 6, 0, 0, time.UTC) + india := utc.In(time.FixedZone("IST", 5*3600+1800)) + if got, want := hashcash(11, "4d74fb15eb23f465f1f6fcbf534e5877", india), hashcash(11, "4d74fb15eb23f465f1f6fcbf534e5877", utc); got != want { + t.Errorf("hashcash in IST = %s, in UTC = %s", got, want) + } +} + func TestHashcashMatchesSpaceship(t *testing.T) { for _, vector := range []struct { bits int From 5dfc509cad8f2221a3cb5537d8cb869fd3fe4331 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:09:59 +0530 Subject: [PATCH 15/29] fix(cli): run a project's Bundler fastlane with bundle exec The fastlane setup ran fastlane from PATH in a temporary folder, so a fastlane installed only through a project's Gemfile was reported as unavailable. When the Gemfile in the folder the CLI runs in, or in its ios folder as Flutter and React Native projects keep it, lists fastlane and bundle is installed, the lane now runs with bundle exec fastlane and BUNDLE_GEMFILE pointing at that Gemfile. Otherwise fastlane from PATH is used as before. A Bundler fastlane that does not start asks for bundle install. --- .../cli/internal/apns/fastlane/adapter.go | 91 ++++++++++++++++--- .../internal/apns/fastlane/adapter_test.go | 73 +++++++++++++++ 2 files changed, 150 insertions(+), 14 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index 418b2c92be..eae7f906c3 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -2,8 +2,9 @@ // open-source Apple Developer portal client. // // It runs a lane from a Fastfile embedded in the binary with the fastlane the -// person already has installed, so it works with a gem, Homebrew or bundler -// installation and brings no Ruby of its own. It needs fastlane 2.225.0 or +// person already has installed and brings no Ruby of its own: the project's +// Bundler fastlane when its Gemfile lists one, otherwise fastlane from PATH +// (a gem or Homebrew installation). It needs fastlane 2.225.0 or // later, the first release whose spaceship signs in with SRP. package fastlane @@ -34,8 +35,14 @@ var minimumVersion = [3]int{2, 225, 0} // Adapter creates APNs keys with an installed fastlane. type Adapter struct { - // Fastlane is the command to run. Empty means "fastlane" from PATH. + // Fastlane is the command to run. Empty means the project's Bundler + // fastlane when there is one, otherwise "fastlane" from PATH. Fastlane string + // Bundle is Bundler's command. Empty means "bundle" from PATH. + Bundle string + // WorkDir is the project folder whose Gemfile may list fastlane. Empty + // means the current directory. + WorkDir string // spaceship prompts for the two-factor code, and for a team when the // account has several, on the terminal. Nil means the process's own. Stdin io.Reader @@ -70,9 +77,13 @@ type result struct { // 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. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { - if err := a.checkFastlane(ctx); err != nil { + runner := a.runner() + if err := runner.check(ctx); err != nil { return apns.Key{}, err } + if runner.gemfile != "" && request.Log != nil { + request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) + } appleID := strings.TrimSpace(request.AppleID) if appleID == "" { @@ -109,10 +120,10 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if request.TeamID != "" { arguments = append(arguments, "team_id:"+request.TeamID) } - command := exec.CommandContext(ctx, a.fastlane(), arguments...) + command := runner.command(ctx, arguments...) command.Dir = scratch command.Stdin, command.Stdout, command.Stderr = a.terminal() - command.Env = append(os.Environ(), + command.Env = append(command.Env, "FASTLANE_USER="+appleID, "FASTLANE_PASSWORD="+password, "FASTLANE_SKIP_UPDATE_CHECK=1", @@ -178,27 +189,79 @@ func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { return stdin, stdout, stderr } -func (a *Adapter) fastlane() string { +// runner is how fastlane is started: a command, the arguments before the +// lane's, and the environment it needs. +type runner struct { + name string + prefix []string + env []string + gemfile string +} + +var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) + +// runner picks the configured command, then the project's Bundler fastlane, +// then fastlane from PATH. A Bundler fastlane must run with bundle exec and +// the project's Gemfile, since the lane runs in a temporary folder. +func (a *Adapter) runner() runner { + base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") if a.Fastlane != "" { - return a.Fastlane + return runner{name: a.Fastlane, env: base} + } + bundle := a.Bundle + if bundle == "" { + bundle = "bundle" + } + if gemfile := projectGemfile(a.WorkDir); gemfile != "" { + if _, err := exec.LookPath(bundle); err == nil { + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile} + } } - return "fastlane" + return runner{name: "fastlane", env: base} } -var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) +// projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and +// React Native projects keep it, when it lists fastlane. +func projectGemfile(dir string) string { + if dir == "" { + dir, _ = os.Getwd() + } + for _, candidate := range []string{filepath.Join(dir, "Gemfile"), filepath.Join(dir, "ios", "Gemfile")} { + contents, err := os.ReadFile(candidate) + if err != nil || !gemfileFastlane.Match(contents) { + continue + } + if absolute, err := filepath.Abs(candidate); err == nil { + return absolute + } + } + + return "" +} + +func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { + command := exec.CommandContext(ctx, r.name, append(append([]string{}, r.prefix...), arguments...)...) + command.Env = append([]string{}, r.env...) -func (a *Adapter) checkFastlane(ctx context.Context) error { - command := exec.CommandContext(ctx, a.fastlane(), "--version") - command.Env = append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") - output, err := command.Output() + return command +} + +func (r runner) check(ctx context.Context) error { + output, err := r.command(ctx, "--version").Output() if err != nil { + if r.gemfile != "" { + return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile) + } + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later installed (https://docs.fastlane.tools)", apns.ErrUnavailable, versionString(minimumVersion)) } return supportedVersion(string(output)) } +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + func supportedVersion(output string) error { match := versionPattern.FindStringSubmatch(output) if match == nil { diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 82913dc2fc..5710afae68 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -3,6 +3,7 @@ package fastlane import ( "context" "errors" + "fmt" "os" "path/filepath" "runtime" @@ -164,3 +165,75 @@ func TestCreateKeyNeedsARecentFastlane(t *testing.T) { t.Fatalf("missing fastlane err = %v", err) } } + +func TestProjectGemfile(t *testing.T) { + root := t.TempDir() + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("found %s in an empty folder", gemfile) + } + + if err := os.WriteFile(filepath.Join(root, "Gemfile"), []byte("source \"https://rubygems.org\"\ngem \"cocoapods\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("used a Gemfile without fastlane: %s", gemfile) + } + + if err := os.MkdirAll(filepath.Join(root, "ios"), 0o700); err != nil { + t.Fatal(err) + } + ios := filepath.Join(root, "ios", "Gemfile") + if err := os.WriteFile(ios, []byte("source 'https://rubygems.org'\n\n gem 'fastlane', '~> 2.228'\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != ios { + t.Errorf("projectGemfile = %q, want %q", gemfile, ios) + } +} + +func TestCreateKeyRunsTheProjectsBundlerFastlane(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + bundle := filepath.Join(bin, "bundle") + script := `#!/bin/sh +echo "bundle $* BUNDLE_GEMFILE=$BUNDLE_GEMFILE" >> "` + log + `" +[ "$1 $2" = "exec fastlane" ] || exit 1 +if [ "$3" = "--version" ]; then echo "fastlane 2.228.0"; exit 0; fi +test -f fastlane/Fastfile || exit 1 +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "${argument#out:}" ;; + esac +done +` + if err := os.WriteFile(bundle, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: bundle, WorkDir: project, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", + Log: func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 2 || lines[0] != "bundle exec fastlane --version BUNDLE_GEMFILE="+gemfile || + !strings.HasPrefix(lines[1], "bundle exec fastlane create_apns_key out:") || !strings.HasSuffix(lines[1], "BUNDLE_GEMFILE="+gemfile) { + t.Errorf("calls:\n%s", calls) + } + if len(logged) != 1 || logged[0] != "Using fastlane from "+gemfile+" with bundle exec." { + t.Errorf("logged %v", logged) + } +} From e3b53f6eb0b1e41041b50e28c080d72460eb83bc Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:10:44 +0530 Subject: [PATCH 16/29] test(cli): cover the expo helper call without a team The CLI leaves the team out when none is known and lets the sign-in pick it. The test now checks that the helper then gets no --team-id. --- templates/cli/internal/apns/expo/adapter_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go index c03ca3986b..04dec07b07 100644 --- a/templates/cli/internal/apns/expo/adapter_test.go +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -121,8 +121,10 @@ func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { } } -func TestCreateKeyNotesTheEnvironmentItCannotRestrict(t *testing.T) { - node, npm, _ := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) +// Without a team, the helper gets no --team-id and apple-utils picks the +// account's only team or asks, the flow the CLI uses when no team is known. +func TestCreateKeyWithoutATeamNotesTheEnvironment(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} var logged []string request := apns.Request{ @@ -138,6 +140,12 @@ func TestCreateKeyNotesTheEnvironmentItCannotRestrict(t *testing.T) { if len(logged) != 1 || !strings.Contains(logged[0], "both APNs environments") { t.Errorf("logged %v", logged) } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + helper := lines[len(lines)-1] + if !strings.Contains(helper, "helper.js --name Appwrite Push --out ") || strings.Contains(helper, "--team-id") { + t.Errorf("helper call = %s", helper) + } } func TestCreateKeyNeedsARecentNode(t *testing.T) { From b90e5bf055f3e41474227287119d2e9a2f912b50 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:29:40 +0530 Subject: [PATCH 17/29] fix(cli): do not run another fastlane when the project pins one When the project's Gemfile lists fastlane but Bundler is missing, the fastlane setup fell back to fastlane from PATH, which can be a different version from the pinned one. It now reports apns.ErrUnavailable naming the Gemfile and how to install Bundler. --- .../cli/internal/apns/fastlane/adapter.go | 21 ++++++++++++------- .../internal/apns/fastlane/adapter_test.go | 14 +++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index eae7f906c3..ee72b6d0d2 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -77,7 +77,10 @@ type result struct { // 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. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { - runner := a.runner() + runner, err := a.runner() + if err != nil { + return apns.Key{}, err + } if err := runner.check(ctx); err != nil { return apns.Key{}, err } @@ -202,23 +205,27 @@ var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) // runner picks the configured command, then the project's Bundler fastlane, // then fastlane from PATH. A Bundler fastlane must run with bundle exec and -// the project's Gemfile, since the lane runs in a temporary folder. -func (a *Adapter) runner() runner { +// the project's Gemfile, since the lane runs in a temporary folder. When the +// project pins fastlane but Bundler is missing, it stops rather than run a +// fastlane other than the pinned one. +func (a *Adapter) runner() (runner, error) { base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") if a.Fastlane != "" { - return runner{name: a.Fastlane, env: base} + return runner{name: a.Fastlane, env: base}, nil } bundle := a.Bundle if bundle == "" { bundle = "bundle" } if gemfile := projectGemfile(a.WorkDir); gemfile != "" { - if _, err := exec.LookPath(bundle); err == nil { - return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile} + if _, err := exec.LookPath(bundle); err != nil { + return runner{}, fmt.Errorf("%w: %s pins fastlane, but Bundler is not installed. Install it with gem install bundler, then run bundle install there", apns.ErrUnavailable, gemfile) } + + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile}, nil } - return runner{name: "fastlane", env: base} + return runner{name: "fastlane", env: base}, nil } // projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 5710afae68..8a95dafa4a 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -237,3 +237,17 @@ done t.Errorf("logged %v", logged) } } + +func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: filepath.Join(t.TempDir(), "bundle"), WorkDir: project} + + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), gemfile+" pins fastlane, but Bundler is not installed") { + t.Fatalf("err = %v", err) + } +} From b5c88ee13364ed5d4a42822b0ebb63d8dade3ad3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 15:31:49 +0530 Subject: [PATCH 18/29] fix(cli): bound and cancel the hashcash search Any difficulty up to 64 bits was answered, and the search ignored cancellation, so an abnormal challenge could keep init apns spinning indefinitely. Challenges above 24 bits (Apple asks for about 11; 24 is some 16 million attempts) are now left unanswered, as one sent without a challenge is, and the search checks the context every 65536 attempts so Ctrl-C stops it. --- .../internal/apns/appwrite/adapter_test.go | 29 +++++++++++++++---- templates/cli/internal/apns/appwrite/auth.go | 21 ++++++++++---- templates/cli/internal/apns/appwrite/srp.go | 13 +++++++-- .../cli/internal/apns/appwrite/srp_test.go | 16 ++++++++-- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index 77277842e3..0eaada0275 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -45,9 +45,11 @@ type fakeApple struct { // every session handed out before. generation int textsSent int - teams []Team - keys []apns.ExistingKey - maxKeys bool + // hashcashBits is the difficulty asked for; empty means 8. + hashcashBits string + teams []Team + keys []apns.ExistingKey + maxKeys bool salt []byte b *big.Int @@ -133,13 +135,19 @@ func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { }) case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth/signin": - w.Header().Set("X-Apple-HC-Bits", "8") + bits := f.hashcashBits + if bits == "" { + bits = "8" + } + w.Header().Set("X-Apple-HC-Bits", bits) w.Header().Set("X-Apple-HC-Challenge", "hc-challenge") w.WriteHeader(http.StatusOK) case r.URL.Path == "/appleauth/auth/signin/complete" && idmsa: token := r.Header.Get("X-Apple-HC") - if !strings.Contains(token, ":hc-challenge::") || leadingZeroBits(sha1.Sum([]byte(token))) < 8 { + if f.hashcashBits != "" && token != "" { + f.t.Errorf("answered a %s-bit hashcash: %q", f.hashcashBits, token) + } else if f.hashcashBits == "" && (!strings.Contains(token, ":hc-challenge::") || leadingZeroBits(sha1.Sum([]byte(token))) < 8) { f.t.Errorf("hashcash = %q", token) } if params["c"] != "challenge-c" || params["accountName"] != testAppleID { @@ -601,4 +609,15 @@ func TestChoosingATextMessageIsNotACodeAttempt(t *testing.T) { t.Errorf("texts = %d, asked %v", fake.textsSent, asker.asked) } } + +func TestAnImpracticalHashcashIsNotAnswered(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.hashcashBits = "40" + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } +} {% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go index 690f45df75..63d521dca8 100644 --- a/templates/cli/internal/apns/appwrite/auth.go +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -280,7 +280,11 @@ func (p *portalClient) signIn(ctx context.Context, accountName, password string) } headers := p.idmsaHeaders() - if token := p.hashcash(ctx); token != "" { + token, err := p.hashcash(ctx) + if err != nil { + return err + } + if token != "" { headers["X-Apple-HC"] = token } response, payload, err = p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/complete?isRememberMeEnabled=false", map[string]any{ @@ -319,19 +323,24 @@ func (p *portalClient) signIn(ctx context.Context, accountName, password string) // hashcash answers the proof-of-work challenge Apple hands out on the sign-in // page. Without one, Apple decides; spaceship carries on in that case too. -func (p *portalClient) hashcash(ctx context.Context) string { +// maxHashcashBits bounds the work: Apple asks for about 11 bits, and 24 is +// some 16 million attempts, a few seconds. A harder challenge is not +// answered, as one Apple sends without a challenge is not. +const maxHashcashBits = 24 + +func (p *portalClient) hashcash(ctx context.Context) (string, error) { response, _, err := p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth/signin?widgetKey="+url.QueryEscape(p.widgetKey), nil, nil) if err != nil { - return "" + return "", nil } bits, err := strconv.Atoi(response.Header.Get("X-Apple-HC-Bits")) challenge := response.Header.Get("X-Apple-HC-Challenge") - if err != nil || challenge == "" || bits < 0 || bits > 64 { - return "" + if err != nil || challenge == "" || bits < 0 || bits > maxHashcashBits { + return "", nil } - return hashcash(bits, challenge, p.now()) + return hashcash(ctx, bits, challenge, p.now()) } type trustedPhone struct { diff --git a/templates/cli/internal/apns/appwrite/srp.go b/templates/cli/internal/apns/appwrite/srp.go index c3450b1b99..91ab23d115 100644 --- a/templates/cli/internal/apns/appwrite/srp.go +++ b/templates/cli/internal/apns/appwrite/srp.go @@ -1,6 +1,7 @@ package appwrite import ( + "context" "crypto/pbkdf2" "crypto/sha1" "crypto/sha256" @@ -146,16 +147,24 @@ func (c *srpClient) proofs(accountName string, key, salt, serverB []byte) ([]byt // "1:bits:date:challenge::counter" string has a SHA-1 starting with bits // zero bits. The date is in UTC, as @expo/apple-utils sends it (spaceship // uses local time, which only matches on a UTC machine). -func hashcash(bits int, challenge string, now time.Time) string { +// The search stops when ctx is done, checked every hashcashBatch attempts. +func hashcash(ctx context.Context, bits int, challenge string, now time.Time) (string, error) { prefix := "1:" + strconv.Itoa(bits) + ":" + now.UTC().Format("20060102150405") + ":" + challenge + "::" for counter := 0; ; counter++ { + if counter%hashcashBatch == 0 { + if err := ctx.Err(); err != nil { + return "", err + } + } token := prefix + strconv.Itoa(counter) if leadingZeroBits(sha1.Sum([]byte(token))) >= bits { - return token + return token, nil } } } +const hashcashBatch = 1 << 16 + func leadingZeroBits(sum [sha1.Size]byte) int { count := 0 for _, value := range sum { diff --git a/templates/cli/internal/apns/appwrite/srp_test.go b/templates/cli/internal/apns/appwrite/srp_test.go index 91d17b7d0a..55f2fae534 100644 --- a/templates/cli/internal/apns/appwrite/srp_test.go +++ b/templates/cli/internal/apns/appwrite/srp_test.go @@ -2,8 +2,10 @@ package appwrite import ( "bytes" + "context" "encoding/base64" "encoding/hex" + "errors" "math/big" "testing" "time" @@ -90,10 +92,20 @@ func TestSRPRejectsAnInvalidChallenge(t *testing.T) { } } +func TestHashcashStopsWhenCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if token, err := hashcash(ctx, 64, "challenge", time.Now()); !errors.Is(err, context.Canceled) || token != "" { + t.Errorf("hashcash = %q, %v", token, err) + } +} + func TestHashcashUsesUTC(t *testing.T) { utc := time.Date(2023, 2, 23, 17, 6, 0, 0, time.UTC) india := utc.In(time.FixedZone("IST", 5*3600+1800)) - if got, want := hashcash(11, "4d74fb15eb23f465f1f6fcbf534e5877", india), hashcash(11, "4d74fb15eb23f465f1f6fcbf534e5877", utc); got != want { + got, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", india) + want, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", utc) + if got != want { t.Errorf("hashcash in IST = %s, in UTC = %s", got, want) } } @@ -110,7 +122,7 @@ func TestHashcashMatchesSpaceship(t *testing.T) { if err != nil { t.Fatal(err) } - if got := hashcash(vector.bits, vector.challenge, date); got != vector.token { + if got, err := hashcash(context.Background(), vector.bits, vector.challenge, date); err != nil || got != vector.token { t.Errorf("hashcash(%d, %s) = %s, want %s", vector.bits, vector.challenge, got, vector.token) } } From fa93a7b6629bc6d8fd8095aba5496c25ebfe79cd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 15:41:57 +0530 Subject: [PATCH 19/29] feat(cli): download Node.js for the expo APNs key setup when missing The expo setup stopped when node or npm was missing, or node was older than 18. It now downloads the Node.js LTS (v24.21.0) for this OS and CPU from nodejs.org into the setup's session folder, checks the archive against the SHA-256 pinned for each official build, unpacks it beside its final folder and moves it into place, and runs npm through node. Nothing is installed system-wide, and later runs reuse it. Entries that would unpack outside the folder are refused and symbolic links (npm's shell wrappers) are skipped. A node set on the adapter is checked but never replaced. --- src/SDK/Language/CLI.php | 10 + templates/cli/internal/apns/expo/adapter.go | 94 ++++-- templates/cli/internal/apns/expo/runtime.go | 272 ++++++++++++++++++ .../cli/internal/apns/expo/runtime_test.go | 206 +++++++++++++ 4 files changed, 561 insertions(+), 21 deletions(-) create mode 100644 templates/cli/internal/apns/expo/runtime.go create mode 100644 templates/cli/internal/apns/expo/runtime_test.go diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index b3b8281a84..88c835a47b 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1221,6 +1221,16 @@ public function getFiles(): array 'destination' => 'internal/apns/expo/helper/package-lock.json', 'template' => 'cli/internal/apns/expo/helper/package-lock.json', ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/runtime.go', + 'template' => 'cli/internal/apns/expo/runtime.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/runtime_test.go', + 'template' => 'cli/internal/apns/expo/runtime_test.go', + ], [ 'scope' => 'default', 'destination' => 'internal/apns/fastlane/adapter.go', diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index 86eef419c7..5850e472da 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -4,7 +4,8 @@ // It runs a small Node script embedded in the binary, with its package.json // and a lockfile that pins @expo/apple-utils 2.2.1 (MIT) by hash. They are // installed into the session folder on first use with npm ci -// --ignore-scripts. It needs Node.js 18 or later. +// --ignore-scripts. It runs on Node.js 18 or later, and downloads the Node.js +// LTS into the session folder when the machine has none it can use. package expo import ( @@ -15,6 +16,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" @@ -31,9 +33,16 @@ const minimumNodeMajor = 18 // Adapter creates APNs keys with @expo/apple-utils. type Adapter struct { - // Node and Npm are the commands to run. Empty means "node" and "npm". + // Node and Npm are the commands to run. Empty means "node" and "npm" + // from PATH, or a downloaded Node.js when those are missing or too old. Node string Npm string + // For tests: where Node.js builds are downloaded from, the client, and + // the platform. Zero values mean nodejs.org and this machine. + NodeDist string + HTTP *http.Client + GOOS string + GOARCH string // apple-utils prompts for anything the request does not answer, and for // the two-factor code, on the terminal. Nil means the process's own. Stdin io.Reader @@ -68,8 +77,9 @@ type result struct { // 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 err := a.checkNode(ctx); err != nil { - return apns.Key{}, err + log := request.Log + if log == nil { + log = func(string, ...any) {} } dir := request.SessionDir if dir == "" { @@ -80,11 +90,15 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key defer os.RemoveAll(scratch) dir = scratch } - if err := a.install(ctx, dir); err != nil { + tools, err := a.toolchain(ctx, dir, log) + if err != nil { + return apns.Key{}, err + } + if err := a.install(ctx, dir, tools); err != nil { return apns.Key{}, err } - 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) + if request.Environment != apns.EnvironmentAll { + log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) } scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") @@ -98,13 +112,13 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if request.TeamID != "" { arguments = append(arguments, "--team-id", request.TeamID) } - command := exec.CommandContext(ctx, a.node(), arguments...) + command := exec.CommandContext(ctx, tools.node, arguments...) command.Dir = dir command.Stdin, command.Stdout, command.Stderr = a.terminal() // apple-utils reads the Apple ID and password from these instead of // asking, and stores the password in the macOS Keychain unless told not // to. The CLI never keeps the password. - command.Env = append(os.Environ(), "EXPO_NO_KEYCHAIN=1") + command.Env = append(tools.env(), "EXPO_NO_KEYCHAIN=1") if request.AppleID != "" { command.Env = append(command.Env, "EXPO_APPLE_ID="+request.AppleID) } @@ -170,14 +184,6 @@ func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { return stdin, stdout, stderr } -func (a *Adapter) node() string { - if a.Node != "" { - return a.Node - } - - return "node" -} - func (a *Adapter) npm() string { if a.Npm != "" { return a.Npm @@ -188,8 +194,8 @@ func (a *Adapter) npm() string { var nodeVersion = regexp.MustCompile(`^v?(\d+)\.`) -func (a *Adapter) checkNode(ctx context.Context) error { - output, err := exec.CommandContext(ctx, a.node(), "--version").Output() +func checkNode(ctx context.Context, node string) error { + output, err := exec.CommandContext(ctx, node, "--version").Output() if err != nil { return fmt.Errorf("%w: the expo setup needs Node.js %d or later", apns.ErrUnavailable, minimumNodeMajor) } @@ -197,6 +203,50 @@ func (a *Adapter) checkNode(ctx context.Context) error { return supportedNode(string(bytes.TrimSpace(output))) } +// toolchain is how node and npm are run. +type toolchain struct { + node string + npm []string + // bin is put first on PATH when node is a downloaded one. + bin string +} + +func (t toolchain) env() []string { + env := os.Environ() + if t.bin != "" { + env = append(env, "PATH="+t.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + } + + return env +} + +// toolchain uses the configured node, then node and npm from PATH, then the +// Node.js downloaded into dir. A configured node is never replaced. +func (a *Adapter) toolchain(ctx context.Context, dir string, log func(string, ...any)) (toolchain, error) { + if a.Node != "" { + if err := checkNode(ctx, a.Node); err != nil { + return toolchain{}, err + } + + return toolchain{node: a.Node, npm: []string{a.npm()}}, nil + } + if checkNode(ctx, "node") == nil { + if _, err := exec.LookPath(a.npm()); err == nil { + return toolchain{node: "node", npm: []string{a.npm()}}, nil + } + } + + node, npm, err := a.downloadNode(ctx, filepath.Join(dir, "runtime"), log) + if err != nil { + return toolchain{}, fmt.Errorf("%w: the expo setup needs Node.js %d or later, and downloading it failed: %v", apns.ErrUnavailable, minimumNodeMajor, err) + } + if err := checkNode(ctx, node); err != nil { + return toolchain{}, err + } + + return toolchain{node: node, npm: []string{node, npm}, bin: filepath.Dir(node)}, nil +} + func supportedNode(version string) error { match := nodeVersion.FindStringSubmatch(version) if match == nil { @@ -213,7 +263,7 @@ func supportedNode(version string) error { // lockfile changed or the dependencies are missing. The lockfile pins the // exact package hashes, and --ignore-scripts keeps install scripts from // running. -func (a *Adapter) install(ctx context.Context, dir string) error { +func (a *Adapter) install(ctx context.Context, dir string, tools toolchain) error { if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -237,8 +287,10 @@ func (a *Adapter) install(ctx context.Context, dir string) error { return nil } _, _, stderr := a.terminal() - command := exec.CommandContext(ctx, a.npm(), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + arguments := append(append([]string{}, tools.npm[1:]...), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + command := exec.CommandContext(ctx, tools.npm[0], arguments...) command.Dir = dir + command.Env = tools.env() command.Stdout, command.Stderr = stderr, stderr if err := command.Run(); err != nil { // A failed install must not look complete on the next run. diff --git a/templates/cli/internal/apns/expo/runtime.go b/templates/cli/internal/apns/expo/runtime.go new file mode 100644 index 0000000000..988a421871 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime.go @@ -0,0 +1,272 @@ +package expo + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// When the machine has no Node.js the expo setup can use, it downloads this +// release, the Active LTS, into the session folder. Nothing is installed +// system-wide. Each build is checked against its SHA-256 from +// https://nodejs.org/dist/v24.21.0/SHASUMS256.txt before it is unpacked. +const nodeRelease = "v24.21.0" + +var nodeChecksums = map[string]string{ + "node-v24.21.0-darwin-arm64.tar.gz": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "node-v24.21.0-darwin-x64.tar.gz": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", + "node-v24.21.0-linux-arm64.tar.gz": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5", + "node-v24.21.0-linux-x64.tar.gz": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "node-v24.21.0-win-arm64.zip": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921", + "node-v24.21.0-win-x64.zip": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541", +} + +const defaultNodeDist = "https://nodejs.org/dist" + +// nodeBuild names the official build for goos/goarch, without its extension, +// and the archive format it comes in. +func nodeBuild(goos, goarch string) (string, string, error) { + platform := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win"}[goos] + arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[goarch] + if platform == "" || arch == "" { + return "", "", fmt.Errorf("no Node.js build to download for %s/%s", goos, goarch) + } + extension := ".tar.gz" + if goos == "windows" { + extension = ".zip" + } + + return "node-" + nodeRelease + "-" + platform + "-" + arch, extension, nil +} + +// nodePaths are the node binary and npm's entry script inside an unpacked +// build. npm is run through node, so its shell wrappers are not needed. +func nodePaths(root, goos string) (string, string) { + if goos == "windows" { + return filepath.Join(root, "node.exe"), filepath.Join(root, "node_modules", "npm", "bin", "npm-cli.js") + } + + return filepath.Join(root, "bin", "node"), filepath.Join(root, "lib", "node_modules", "npm", "bin", "npm-cli.js") +} + +// downloadNode makes sure the pinned Node.js is unpacked under dir and returns +// the node binary and npm's entry script. +func (a *Adapter) downloadNode(ctx context.Context, dir string, log func(string, ...any)) (string, string, error) { + goos, goarch := a.platform() + name, extension, err := nodeBuild(goos, goarch) + if err != nil { + return "", "", err + } + root := filepath.Join(dir, name) + node, npm := nodePaths(root, goos) + if _, err := os.Stat(npm); err == nil { + return node, npm, nil + } + archive := name + extension + checksum, ok := nodeChecksums[archive] + if !ok { + return "", "", fmt.Errorf("no checksum for %s", archive) + } + + dist := a.NodeDist + if dist == "" { + dist = defaultNodeDist + } + log("Node.js %d or later was not found. Downloading Node.js %s (about 50 MB, 200 MB unpacked) into %s ...", minimumNodeMajor, nodeRelease, dir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + downloaded, err := a.fetch(ctx, dist+"/"+nodeRelease+"/"+archive, dir, checksum) + if err != nil { + return "", "", err + } + defer os.Remove(downloaded) + + // Unpack next to the final folder and move it into place, so a failed or + // interrupted unpack never looks like a usable runtime. + staging, err := os.MkdirTemp(dir, ".node-") + if err != nil { + return "", "", err + } + defer os.RemoveAll(staging) + if extension == ".zip" { + err = unzip(downloaded, staging) + } else { + err = untar(downloaded, staging) + } + if err != nil { + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + _ = os.RemoveAll(root) + if err := os.Rename(filepath.Join(staging, name), root); err != nil { + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + if _, err := os.Stat(npm); err != nil { + return "", "", fmt.Errorf("%s has no npm", archive) + } + + return node, npm, nil +} + +func (a *Adapter) platform() (string, string) { + if a.GOOS != "" { + return a.GOOS, a.GOARCH + } + + return runtime.GOOS, runtime.GOARCH +} + +// fetch downloads url into a file in dir and checks its SHA-256. +func (a *Adapter) fetch(ctx context.Context, url, dir, checksum string) (string, error) { + client := a.HTTP + if client == nil { + client = &http.Client{Timeout: 10 * time.Minute} + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("could not download Node.js: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("could not download Node.js from %s (HTTP %d)", url, response.StatusCode) + } + + file, err := os.CreateTemp(dir, ".node-download-") + if err != nil { + return "", err + } + hash := sha256.New() + _, err = io.Copy(io.MultiWriter(file, hash), response.Body) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + os.Remove(file.Name()) + + return "", fmt.Errorf("could not download Node.js: %w", err) + } + if got := hex.EncodeToString(hash.Sum(nil)); got != checksum { + os.Remove(file.Name()) + + return "", fmt.Errorf("the Node.js download from %s does not match its checksum (got %s)", url, got) + } + + return file.Name(), nil +} + +// inside returns where name unpacks under root, refusing anything that would +// land outside it. +func inside(root, name string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(name)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe path %q in the archive", name) + } + + return filepath.Join(root, clean), nil +} + +func writeFile(path string, mode os.FileMode, contents io.Reader) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&0o755|0o600) + if err != nil { + return err + } + _, err = io.Copy(file, contents) + if closeErr := file.Close(); err == nil { + err = closeErr + } + + return err +} + +// untar unpacks a .tar.gz into root. Symbolic links are skipped: they are +// only npm's and npx's shell entry points, and npm is run through node. +func untar(archive, root string) error { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + compressed, err := gzip.NewReader(file) + if err != nil { + return err + } + reader := tar.NewReader(compressed) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + path, err := inside(root, header.Name) + if err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := writeFile(path, header.FileInfo().Mode(), reader); err != nil { + return err + } + } + } +} + +// unzip unpacks a .zip into root, with the same rules as untar. +func unzip(archive, root string) error { + reader, err := zip.OpenReader(archive) + if err != nil { + return err + } + defer reader.Close() + for _, entry := range reader.File { + path, err := inside(root, entry.Name) + if err != nil { + return err + } + if entry.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + + continue + } + if !entry.FileInfo().Mode().IsRegular() { + continue + } + contents, err := entry.Open() + if err != nil { + return err + } + err = writeFile(path, entry.FileInfo().Mode(), contents) + contents.Close() + if err != nil { + return err + } + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/runtime_test.go b/templates/cli/internal/apns/expo/runtime_test.go new file mode 100644 index 0000000000..e7f9a01d69 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -0,0 +1,206 @@ +package expo + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestNodeBuild(t *testing.T) { + for _, test := range []struct{ goos, goarch, name, extension string }{ + {"darwin", "arm64", "node-v24.21.0-darwin-arm64", ".tar.gz"}, + {"darwin", "amd64", "node-v24.21.0-darwin-x64", ".tar.gz"}, + {"linux", "amd64", "node-v24.21.0-linux-x64", ".tar.gz"}, + {"windows", "arm64", "node-v24.21.0-win-arm64", ".zip"}, + } { + name, extension, err := nodeBuild(test.goos, test.goarch) + if err != nil || name != test.name || extension != test.extension { + t.Errorf("nodeBuild(%s, %s) = %s, %s, %v", test.goos, test.goarch, name, extension, err) + } + if _, ok := nodeChecksums[name+extension]; !ok { + t.Errorf("no checksum for %s%s", name, extension) + } + } + if _, _, err := nodeBuild("plan9", "386"); err == nil { + t.Error("found a build for plan9/386") + } +} + +type entry struct { + name string + mode int64 + contents string + link string +} + +func tarball(t *testing.T, entries []entry) []byte { + t.Helper() + var buffer bytes.Buffer + compressed := gzip.NewWriter(&buffer) + writer := tar.NewWriter(compressed) + for _, item := range entries { + header := &tar.Header{Name: item.name, Mode: item.mode, Size: int64(len(item.contents)), Typeflag: tar.TypeReg} + if item.link != "" { + header = &tar.Header{Name: item.name, Mode: 0o777, Linkname: item.link, Typeflag: tar.TypeSymlink} + } + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(item.contents)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + + return buffer.Bytes() +} + +// fakeNodeBuild is a Node.js build whose node is a shell script: it answers +// --version, acts as npm when given npm-cli.js, and otherwise runs as the +// helper, writing a key to the --out file. +func fakeNodeBuild(t *testing.T) (string, []byte) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + name, _, err := nodeBuild(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skip(err) + } + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "v24.21.0"; exit 0; fi +case "$1" in + *npm-cli.js) mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json; exit 0 ;; +esac +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "$2"; fi + shift +done +` + + return name, tarball(t, []entry{ + {name: name + "/bin/node", mode: 0o755, contents: node}, + {name: name + "/lib/node_modules/npm/bin/npm-cli.js", mode: 0o644, contents: "// npm"}, + {name: name + "/bin/npm", link: "../lib/node_modules/npm/bin/npm-cli.js"}, + }) +} + +// serve answers the Node.js download, counting requests, and makes archive +// the expected build for this machine. +func serve(t *testing.T, name string, archive []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + sum := sha256.Sum256(archive) + previous := nodeChecksums[name+".tar.gz"] + nodeChecksums[name+".tar.gz"] = hex.EncodeToString(sum[:]) + t.Cleanup(func() { nodeChecksums[name+".tar.gz"] = previous }) + + requests := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/"+nodeRelease+"/"+name+".tar.gz" { + w.WriteHeader(http.StatusNotFound) + + return + } + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + + return server, requests +} + +// withoutNode leaves only mkdir on PATH, so neither node nor npm is found. +func withoutNode(t *testing.T) { + t.Helper() + bin := t.TempDir() + if err := os.Symlink("/bin/mkdir", filepath.Join(bin, "mkdir")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) +} + +func TestCreateKeyDownloadsNodeWhenMissing(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, requests := serve(t, name, archive) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client(), Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + TeamID: "ABCDE12345", Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + } + if requests.Load() != 1 { + t.Errorf("downloaded Node.js %d times", requests.Load()) + } + if len(logged) != 1 || !strings.Contains(logged[0], "Downloading Node.js %s") { + t.Errorf("logged %v", logged) + } + root := filepath.Join(request.SessionDir, "runtime", name) + if info, err := os.Stat(filepath.Join(root, "bin", "node")); err != nil || info.Mode().Perm()&0o100 == 0 { + t.Errorf("node = %v, %v", info, err) + } + if _, err := os.Lstat(filepath.Join(root, "bin", "npm")); err == nil { + t.Error("unpacked a symbolic link") + } +} + +func TestDownloadNodeRejectsABadChecksum(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + nodeChecksums[name+".tar.gz"] = strings.Repeat("0", 64) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + dir := t.TempDir() + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: dir}) + if err == nil || !strings.Contains(err.Error(), "does not match its checksum") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "runtime", name)); err == nil { + t.Error("unpacked a download that failed its checksum") + } +} + +func TestUntarRefusesPathsOutsideTheFolder(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + archive := filepath.Join(parent, "evil.tar.gz") + if err := os.WriteFile(archive, tarball(t, []entry{ + {name: "../evil", mode: 0o644, contents: "x"}, + }), 0o600); err != nil { + t.Fatal(err) + } + + if err := untar(archive, root); err == nil || !strings.Contains(err.Error(), "unsafe path") { + t.Errorf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(parent, "evil")); err == nil { + t.Error("wrote outside the folder") + } +} From 765bd84013e35a146359595076b7df1892e56c16 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 15:44:07 +0530 Subject: [PATCH 20/29] feat(cli): offer to install fastlane with Homebrew when it is missing The fastlane APNs key setup stopped with a bare 'needs fastlane' when none was installed. When Homebrew is available it now asks to run brew install fastlane on the terminal and carries on. Without Homebrew, a terminal or a yes, it says how to install fastlane (Homebrew, gem or the project's Gemfile) or to use --provider appwrite. An installed fastlane older than 2.225.0 now gets the matching upgrade command: bundle update fastlane for a project's Bundler fastlane, otherwise brew upgrade or gem update. A project's pinned fastlane and an explicit command are never replaced by an install. --- .../cli/internal/apns/fastlane/adapter.go | 66 ++++++++++- .../internal/apns/fastlane/adapter_test.go | 109 +++++++++++++++++- 2 files changed, 169 insertions(+), 6 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index ee72b6d0d2..a037e38659 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -40,6 +40,9 @@ type Adapter struct { Fastlane string // Bundle is Bundler's command. Empty means "bundle" from PATH. Bundle string + // Brew is Homebrew's command, offered to install a missing fastlane. + // Empty means "brew" from PATH. + Brew string // WorkDir is the project folder whose Gemfile may list fastlane. Empty // means the current directory. WorkDir string @@ -81,7 +84,13 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if err != nil { return apns.Key{}, err } - if err := runner.check(ctx); err != nil { + err = runner.check(ctx) + if errors.Is(err, errNotInstalled) { + if err = a.offerInstall(ctx, request); err == nil { + err = runner.check(ctx) + } + } + if err != nil { return apns.Key{}, err } if runner.gemfile != "" && request.Log != nil { @@ -254,22 +263,69 @@ func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { return command } +// errNotInstalled means there is no fastlane on PATH, which installing one +// can fix. +var errNotInstalled = errors.New("fastlane is not installed") + func (r runner) check(ctx context.Context) error { + if r.gemfile == "" && r.name == "fastlane" { + if _, err := exec.LookPath(r.name); err != nil { + return fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + } + } output, err := r.command(ctx, "--version").Output() if err != nil { if r.gemfile != "" { return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile) } - return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later installed (https://docs.fastlane.tools)", apns.ErrUnavailable, versionString(minimumVersion)) + return fmt.Errorf("%w: %s --version failed: %v", apns.ErrUnavailable, r.name, err) + } + upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" + if r.gemfile != "" { + upgrade = "bundle update fastlane in " + filepath.Dir(r.gemfile) + } + + return supportedVersion(string(output), upgrade) +} + +const installHint = "Install it with brew install fastlane (Homebrew) or gem install fastlane, " + + "or add gem \"fastlane\" to your project's Gemfile and run bundle install. " + + "Or use --provider appwrite, which needs nothing installed" + +// offerInstall asks to install fastlane with Homebrew, when there is one, and +// runs it on the terminal. Without Homebrew, a terminal or a yes, it returns +// how to install fastlane instead. +func (a *Adapter) offerInstall(ctx context.Context, request apns.Request) error { + notInstalled := fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + brew := a.Brew + if brew == "" { + brew = "brew" + } + if _, err := exec.LookPath(brew); err != nil || request.Asker == nil { + return notInstalled + } + choice, err := request.Asker.Choose("fastlane is not installed. Install it with Homebrew now?", + []string{"Yes, run brew install fastlane", "No"}) + if err != nil || choice != 0 { + return notInstalled } - return supportedVersion(string(output)) + if request.Log != nil { + request.Log("Running brew install fastlane ...") + } + command := exec.CommandContext(ctx, brew, "install", "fastlane") + command.Stdin, command.Stdout, command.Stderr = a.terminal() + if err := command.Run(); err != nil { + return fmt.Errorf("%w: brew install fastlane failed: %v. %s", apns.ErrUnavailable, err, installHint) + } + + return nil } var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) -func supportedVersion(output string) error { +func supportedVersion(output, upgrade string) error { match := versionPattern.FindStringSubmatch(output) if match == nil { return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) @@ -281,7 +337,7 @@ func supportedVersion(output string) error { for index := range version { if version[index] != minimumVersion[index] { if version[index] < minimumVersion[index] { - return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Run fastlane update_fastlane", apns.ErrUnavailable, versionString(minimumVersion), versionString(version)) + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Upgrade it with %s", apns.ErrUnavailable, versionString(minimumVersion), versionString(version), upgrade) } break diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 8a95dafa4a..e539036341 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -22,7 +22,7 @@ func TestSupportedVersion(t *testing.T) { "fastlane 1.300.0": false, "something else": false, } { - err := supportedVersion(output) + err := supportedVersion(output, "brew upgrade fastlane") if (err == nil) != supported { t.Errorf("supportedVersion(%q) = %v", output, err) } @@ -251,3 +251,110 @@ func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { t.Fatalf("err = %v", err) } } + +// choosingAsker answers every choice with choice and records the questions. +type choosingAsker struct { + choice int + asked []string +} + +func (c *choosingAsker) Ask(question string, secret bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosingAsker) Choose(question string, options []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +// withoutFastlane leaves PATH with only bin, so fastlane is not found unless +// something puts it there. +func withoutFastlane(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + t.Setenv("PATH", bin) + + return bin +} + +// fakeBrew writes a brew stand-in into bin that logs its arguments and, for +// install fastlane, copies fastlane into bin. +func fakeBrew(t *testing.T, bin, fastlane string) string { + t.Helper() + log := filepath.Join(t.TempDir(), "brew.log") + script := `#!/bin/sh +echo "brew $*" >> "` + log + `" +[ "$1 $2" = "install fastlane" ] && /bin/cp "` + fastlane + `" "` + bin + `/fastlane" +` + if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return log +} + +func TestCreateKeyExplainsHowToInstallFastlane(t *testing.T) { + withoutFastlane(t) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{}} + + _, err := (&Adapter{}).CreateKey(context.Background(), request) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + for _, part := range []string{"fastlane is not installed", "brew install fastlane", "gem install fastlane", "--provider appwrite"} { + if !strings.Contains(err.Error(), part) { + t.Errorf("message is missing %q: %v", part, err) + } + } +} + +func TestCreateKeyInstallsFastlaneWithHomebrew(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + key, err := (&Adapter{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}).CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "fastlane is not installed. Install it with Homebrew now?" { + t.Errorf("asked %v", asker.asked) + } + if calls, _ := os.ReadFile(log); strings.TrimSpace(string(calls)) != "brew install fastlane" { + t.Errorf("brew calls = %q", calls) + } + if len(logged) != 1 || logged[0] != "Running brew install fastlane ..." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyDoesNotInstallFastlaneWhenDeclined(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + + _, err := (&Adapter{}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{choice: 1}}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), "brew install fastlane") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew after the offer was declined") + } +} + +func TestOldFastlaneSaysHowToUpgrade(t *testing.T) { + if err := supportedVersion("fastlane 2.220.0", "bundle update fastlane in /app"); err == nil || + !strings.Contains(err.Error(), "found 2.220.0. Upgrade it with bundle update fastlane in /app") { + t.Errorf("err = %v", err) + } +} From f46478f64db4a14f926983d324d51e4206b63353 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 15:58:54 +0530 Subject: [PATCH 21/29] fix(cli): never remove a published Node.js runtime Two expo setups starting together in the same session folder could both download Node.js, and the later one removed the runtime the earlier one had just published, maybe while it was running npm or the helper. Publishing is now only a rename: an existing runtime is never removed, and when another run published first, its complete runtime is used. --- templates/cli/internal/apns/expo/runtime.go | 8 ++++- .../cli/internal/apns/expo/runtime_test.go | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/apns/expo/runtime.go b/templates/cli/internal/apns/expo/runtime.go index 988a421871..fb7e357d4c 100644 --- a/templates/cli/internal/apns/expo/runtime.go +++ b/templates/cli/internal/apns/expo/runtime.go @@ -109,8 +109,14 @@ func (a *Adapter) downloadNode(ctx context.Context, dir string, log func(string, if err != nil { return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) } - _ = os.RemoveAll(root) + // Publishing is a rename, which fails when another run published first; + // that runtime is complete, so it is used rather than replaced. An + // existing runtime is never removed, as another run may be using it. if err := os.Rename(filepath.Join(staging, name), root); err != nil { + if _, statErr := os.Stat(npm); statErr == nil { + return node, npm, nil + } + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) } if _, err := os.Stat(npm); err != nil { diff --git a/templates/cli/internal/apns/expo/runtime_test.go b/templates/cli/internal/apns/expo/runtime_test.go index e7f9a01d69..5d7c2319e2 100644 --- a/templates/cli/internal/apns/expo/runtime_test.go +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -204,3 +204,34 @@ func TestUntarRefusesPathsOutsideTheFolder(t *testing.T) { t.Error("wrote outside the folder") } } + +func TestConcurrentFirstRunsShareOneRuntime(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + dir := t.TempDir() + + const runs = 4 + errs := make(chan error, runs) + for range runs { + go func() { + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + node, _, err := adapter.downloadNode(context.Background(), dir, func(string, ...any) {}) + if err == nil { + _, err = os.Stat(node) + } + errs <- err + }() + } + for range runs { + if err := <-errs; err != nil { + t.Error(err) + } + } + if _, err := os.Stat(filepath.Join(dir, name, "lib", "node_modules", "npm", "bin", "npm-cli.js")); err != nil { + t.Errorf("runtime is incomplete after concurrent runs: %v", err) + } + leftovers, _ := filepath.Glob(filepath.Join(dir, ".node-*")) + if len(leftovers) != 0 { + t.Errorf("left staging files behind: %v", leftovers) + } +} From f0455a1ec3e833da0da160dabc0ed9caa269d98e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 15:59:45 +0530 Subject: [PATCH 22/29] fix(cli): never install over a configured fastlane, and fix the Bundler hint A fastlane command set on the adapter was offered a Homebrew install when it was missing, which could replace a deliberate choice; the offer is now only for fastlane from PATH. The too-old message for a project's Bundler fastlane said 'bundle update fastlane in ', which Bundler reads as gem names; it now says 'cd && bundle update fastlane'. --- .../cli/internal/apns/fastlane/adapter.go | 6 ++- .../internal/apns/fastlane/adapter_test.go | 42 ++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index a037e38659..0d822cb4b5 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -85,7 +85,9 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key return apns.Key{}, err } err = runner.check(ctx) - if errors.Is(err, errNotInstalled) { + // Installing is only offered for fastlane from PATH: a configured command + // is never replaced. + if errors.Is(err, errNotInstalled) && a.Fastlane == "" { if err = a.offerInstall(ctx, request); err == nil { err = runner.check(ctx) } @@ -283,7 +285,7 @@ func (r runner) check(ctx context.Context) error { } upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" if r.gemfile != "" { - upgrade = "bundle update fastlane in " + filepath.Dir(r.gemfile) + upgrade = "cd " + filepath.Dir(r.gemfile) + " && bundle update fastlane" } return supportedVersion(string(output), upgrade) diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index e539036341..a9cb5f83d7 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -353,8 +353,46 @@ func TestCreateKeyDoesNotInstallFastlaneWhenDeclined(t *testing.T) { } func TestOldFastlaneSaysHowToUpgrade(t *testing.T) { - if err := supportedVersion("fastlane 2.220.0", "bundle update fastlane in /app"); err == nil || - !strings.Contains(err.Error(), "found 2.220.0. Upgrade it with bundle update fastlane in /app") { + if err := supportedVersion("fastlane 2.220.0", "cd /app && bundle update fastlane"); err == nil || + !strings.Contains(err.Error(), "found 2.220.0. Upgrade it with cd /app && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestAConfiguredFastlaneIsNeverInstalled(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + + _, err := (&Adapter{Fastlane: "fastlane"}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: asker}) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 { + t.Errorf("offered to install: %v", asker.asked) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew for a configured command") + } +} + +func TestTheBundlerUpgradeCommandRuns(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bundle := filepath.Join(t.TempDir(), "bundle") + if err := os.WriteFile(bundle, []byte("#!/bin/sh\necho \"fastlane 2.220.0\"\n"), 0o700); err != nil { + t.Fatal(err) + } + + _, err := (&Adapter{Bundle: bundle, WorkDir: project}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if err == nil || !strings.Contains(err.Error(), "Upgrade it with cd "+project+" && bundle update fastlane") { t.Errorf("err = %v", err) } } From 86be24b0be85bf754d905e7a3e9aed58e27f77d4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 16:04:28 +0530 Subject: [PATCH 23/29] fix(cli): quote the project folder in the Bundler upgrade hint cd && bundle update fastlane broke for folders with spaces: the shell split the path. The folder is now quoted for the shell people paste into, single quotes on Unix (with a single quote in the name escaped) and double quotes on Windows, and left as is when it has only safe characters. --- .../cli/internal/apns/fastlane/adapter.go | 22 ++++++++++++- .../internal/apns/fastlane/adapter_test.go | 33 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index 0d822cb4b5..9b8c689ffd 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -19,6 +19,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strconv" "strings" @@ -285,12 +286,31 @@ func (r runner) check(ctx context.Context) error { } upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" if r.gemfile != "" { - upgrade = "cd " + filepath.Dir(r.gemfile) + " && bundle update fastlane" + upgrade = "cd " + shellQuote(filepath.Dir(r.gemfile), runtime.GOOS) + " && bundle update fastlane" } return supportedVersion(string(output), upgrade) } +// shellQuote quotes path for the shell people paste a command into: single +// quotes on Unix, with any single quote closed, escaped and reopened, and +// double quotes on Windows, where paths cannot contain one. A path of only +// safe characters is left as it is. +func shellQuote(path, goos string) string { + safe := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-" + if goos == "windows" { + safe += ":\\" + } + if path != "" && strings.Trim(path, safe) == "" { + return path + } + if goos == "windows" { + return `"` + path + `"` + } + + return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" +} + const installHint = "Install it with brew install fastlane (Homebrew) or gem install fastlane, " + "or add gem \"fastlane\" to your project's Gemfile and run bundle install. " + "Or use --provider appwrite, which needs nothing installed" diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index a9cb5f83d7..36a09aa379 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -396,3 +397,35 @@ func TestTheBundlerUpgradeCommandRuns(t *testing.T) { t.Errorf("err = %v", err) } } + +func TestShellQuote(t *testing.T) { + for _, test := range []struct{ path, goos, want string }{ + {"/Users/dev/app", "darwin", "/Users/dev/app"}, + {"/Users/dev/My App", "darwin", "'/Users/dev/My App'"}, + {"/Users/dev/it's", "linux", `'/Users/dev/it'\''s'`}, + {`/Users/dev/back\slash`, "linux", `'/Users/dev/back\slash'`}, + {`C:\Users\dev\app`, "windows", `C:\Users\dev\app`}, + {`C:\Users\dev\My App`, "windows", `"C:\Users\dev\My App"`}, + } { + if got := shellQuote(test.path, test.goos); got != test.want { + t.Errorf("shellQuote(%q, %s) = %s, want %s", test.path, test.goos, got, test.want) + } + } +} + +// The quoted command must work when pasted into a shell, for a folder whose +// name has spaces and a quote. +func TestTheQuotedCdRunsInAShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell") + } + folder := filepath.Join(t.TempDir(), "My Project's App") + if err := os.Mkdir(folder, 0o700); err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder, runtime.GOOS)+" && pwd -P").Output() + resolved, _ := filepath.EvalSymlinks(folder) + if err != nil || strings.TrimSpace(string(output)) != resolved { + t.Errorf("cd landed in %q, %v; want %q", output, err, resolved) + } +} From 941fda72a05d678f616cd5cefda71a7c28762b1f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 16:07:54 +0530 Subject: [PATCH 24/29] fix(cli): name the folder instead of quoting it on Windows Double quotes are not safe on Windows: PowerShell expands $name and $() inside them, so a folder name could change the pasted command or run an expression, cmd expands %, and Windows PowerShell has no &&. No single quoting works in every Windows shell, so the Bundler upgrade hint there names the folder to run bundle update fastlane from. Unix keeps the single-quoted cd command. --- .../cli/internal/apns/fastlane/adapter.go | 29 +++++++++++-------- .../internal/apns/fastlane/adapter_test.go | 22 +++++++------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index 9b8c689ffd..5dbb2b0c75 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -286,27 +286,32 @@ func (r runner) check(ctx context.Context) error { } upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" if r.gemfile != "" { - upgrade = "cd " + shellQuote(filepath.Dir(r.gemfile), runtime.GOOS) + " && bundle update fastlane" + upgrade = bundlerUpgrade(filepath.Dir(r.gemfile), runtime.GOOS) } return supportedVersion(string(output), upgrade) } -// shellQuote quotes path for the shell people paste a command into: single -// quotes on Unix, with any single quote closed, escaped and reopened, and -// double quotes on Windows, where paths cannot contain one. A path of only -// safe characters is left as it is. -func shellQuote(path, goos string) string { - safe := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-" +// bundlerUpgrade says how to upgrade a project's Bundler fastlane. On Unix +// it is one command to paste, with the folder quoted for a POSIX shell. On +// Windows no quoting is safe in every shell (PowerShell expands $ inside +// double quotes, cmd expands %, and Windows PowerShell has no &&), so the +// folder is named rather than put in a command. +func bundlerUpgrade(folder, goos string) string { if goos == "windows" { - safe += ":\\" + return "bundle update fastlane, run from the folder " + folder } - if path != "" && strings.Trim(path, safe) == "" { + + return "cd " + shellQuote(folder) + " && bundle update fastlane" +} + +// shellQuote quotes path for a POSIX shell: in single quotes, with any single +// quote closed, escaped and reopened. A path of only safe characters is left +// as it is. +func shellQuote(path string) string { + if path != "" && strings.Trim(path, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-") == "" { return path } - if goos == "windows" { - return `"` + path + `"` - } return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" } diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 36a09aa379..f6c45457a2 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -398,17 +398,17 @@ func TestTheBundlerUpgradeCommandRuns(t *testing.T) { } } -func TestShellQuote(t *testing.T) { - for _, test := range []struct{ path, goos, want string }{ - {"/Users/dev/app", "darwin", "/Users/dev/app"}, - {"/Users/dev/My App", "darwin", "'/Users/dev/My App'"}, - {"/Users/dev/it's", "linux", `'/Users/dev/it'\''s'`}, - {`/Users/dev/back\slash`, "linux", `'/Users/dev/back\slash'`}, - {`C:\Users\dev\app`, "windows", `C:\Users\dev\app`}, - {`C:\Users\dev\My App`, "windows", `"C:\Users\dev\My App"`}, +func TestBundlerUpgrade(t *testing.T) { + for _, test := range []struct{ folder, goos, want string }{ + {"/Users/dev/app", "darwin", "cd /Users/dev/app && bundle update fastlane"}, + {"/Users/dev/My App", "darwin", "cd '/Users/dev/My App' && bundle update fastlane"}, + {"/Users/dev/it's", "linux", `cd '/Users/dev/it'\''s' && bundle update fastlane`}, + {`/Users/dev/back\slash`, "linux", `cd '/Users/dev/back\slash' && bundle update fastlane`}, + {`/Users/dev/$(whoami)`, "linux", `cd '/Users/dev/$(whoami)' && bundle update fastlane`}, + {`C:\Users\dev\$(Get-Date) App`, "windows", `bundle update fastlane, run from the folder C:\Users\dev\$(Get-Date) App`}, } { - if got := shellQuote(test.path, test.goos); got != test.want { - t.Errorf("shellQuote(%q, %s) = %s, want %s", test.path, test.goos, got, test.want) + if got := bundlerUpgrade(test.folder, test.goos); got != test.want { + t.Errorf("bundlerUpgrade(%q, %s) = %s, want %s", test.folder, test.goos, got, test.want) } } } @@ -423,7 +423,7 @@ func TestTheQuotedCdRunsInAShell(t *testing.T) { if err := os.Mkdir(folder, 0o700); err != nil { t.Fatal(err) } - output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder, runtime.GOOS)+" && pwd -P").Output() + output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder)+" && pwd -P").Output() resolved, _ := filepath.EvalSymlinks(folder) if err != nil || strings.TrimSpace(string(output)) != resolved { t.Errorf("cd landed in %q, %v; want %q", output, err, resolved) From 3558c320274475d972fa4e31487421a6c7cd36d8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 16:40:39 +0530 Subject: [PATCH 25/29] fix(cli): send the Apple team list request without a body The appwrite APNs setup signed in, then failed with HTTP 415 from account/listTeams.action: every portal POST carried a JSON body, and that endpoint, like the portal's other .action endpoints, refuses one. apple-utils and spaceship send it with no body. A portal call with nothing to send now has no body and no Content-Type. The fake portal in the tests answers a body there with 415, as Apple does. --- templates/cli/internal/apns/appwrite/adapter_test.go | 6 ++++++ templates/cli/internal/apns/appwrite/portal.go | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index 0eaada0275..a043a76174 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -241,6 +241,12 @@ func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { switch path { case "account/listTeams.action": + // The real endpoint refuses a JSON body with 415. + if r.Header.Get("Content-Type") != "" || r.ContentLength > 0 { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "teams": f.teams}) case "account/auth/key/list": if params["teamId"] == nil || params["pageSize"] == nil { diff --git a/templates/cli/internal/apns/appwrite/portal.go b/templates/cli/internal/apns/appwrite/portal.go index 0333fb73e1..6dfe7e8715 100644 --- a/templates/cli/internal/apns/appwrite/portal.go +++ b/templates/cli/internal/apns/appwrite/portal.go @@ -82,7 +82,11 @@ func (p *portalClient) portal(ctx context.Context, method, path, teamID string, for name, value := range values { data[name] = value } - body = data + // A call with nothing to send has no body, as the portal's own pages + // send it: its .action endpoints answer a JSON body with 415. + if len(data) > 0 { + body = data + } } headers := map[string]string{"Accept": "application/json, text/plain, */*"} if p.csrf != "" || p.csrfTS != "" { From b1fea8e7453c61f69718a88787786e006355f42a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 16:43:17 +0530 Subject: [PATCH 26/29] fix(cli): send Developer portal requests as forms After the team list, the appwrite APNs setup failed with HTTP 415 from account/auth/key/list: portal POSTs carried JSON. apple-utils and spaceship send them form encoded (application/x-www-form-urlencoded) and use JSON only to create a key. Portal POSTs are now forms, key creation stays JSON, and a call with nothing to send has no body. The fake portal in the tests accepts only those encodings, answering others with 415 as Apple does. --- .../internal/apns/appwrite/adapter_test.go | 24 ++++++++++- templates/cli/internal/apns/appwrite/auth.go | 13 ++++-- .../cli/internal/apns/appwrite/portal.go | 40 +++++++++++++------ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index a043a76174..aaeaa99f5c 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -12,6 +12,7 @@ import ( "math/big" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -102,7 +103,15 @@ func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.record(r) body, _ := io.ReadAll(r.Body) var params map[string]any - _ = json.Unmarshal(body, ¶ms) + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + form, _ := url.ParseQuery(string(body)) + params = map[string]any{} + for name := range form { + params[name] = form.Get(name) + } + } else { + _ = json.Unmarshal(body, ¶ms) + } idmsa := r.Header.Get("X-Apple-Widget-Key") == "widget-key" twoFactorSession := r.Header.Get("X-Apple-Id-Session-Id") == "session-1" && r.Header.Get("scnt") == "scnt-1" @@ -249,7 +258,13 @@ func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, } writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "teams": f.teams}) case "account/auth/key/list": - if params["teamId"] == nil || params["pageSize"] == nil { + // Like Apple, the key list takes a form and refuses JSON with 415. + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + if params["teamId"] == nil || params["pageSize"] != "500" || params["sort"] != "name=asc" { f.t.Errorf("list params = %v", params) } w.Header().Set("csrf", "csrf-token") @@ -260,6 +275,11 @@ func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, } writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": keys}) case "account/auth/key/v2/create": + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } if r.Header.Get("csrf") != "csrf-token" || r.Header.Get("csrf_ts") != "csrf-ts" { f.t.Errorf("create sent csrf %q / %q", r.Header.Get("csrf"), r.Header.Get("csrf_ts")) } diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go index 63d521dca8..a912188c89 100644 --- a/templates/cli/internal/apns/appwrite/auth.go +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -98,20 +98,27 @@ func (e serviceErrors) has(code string) bool { // request sends one request and reads the whole response. func (p *portalClient) request(ctx context.Context, method, target string, body any, headers map[string]string) (*http.Response, []byte, error) { var reader io.Reader - if body != nil { + contentType := "" + switch value := body.(type) { + case nil: + case url.Values: + reader = strings.NewReader(value.Encode()) + contentType = "application/x-www-form-urlencoded" + default: encoded, err := json.Marshal(body) if err != nil { return nil, nil, err } reader = bytes.NewReader(encoded) + contentType = "application/json" } request, err := http.NewRequestWithContext(ctx, method, target, reader) if err != nil { return nil, nil, err } request.Header.Set("User-Agent", "Appwrite CLI") - if body != nil { - request.Header.Set("Content-Type", "application/json") + if contentType != "" { + request.Header.Set("Content-Type", contentType) } for name, value := range headers { request.Header.Set(name, value) diff --git a/templates/cli/internal/apns/appwrite/portal.go b/templates/cli/internal/apns/appwrite/portal.go index 6dfe7e8715..59c19752b7 100644 --- a/templates/cli/internal/apns/appwrite/portal.go +++ b/templates/cli/internal/apns/appwrite/portal.go @@ -60,9 +60,20 @@ func (r portalResult) message() string { return "" } +// bodyEncoding is how a POST to the portal sends its values. +type bodyEncoding int + +const ( + // formBody is what the portal expects, as apple-utils and spaceship send + // it: an endpoint answers a JSON body with 415. + formBody bodyEncoding = iota + // jsonBody is only for creating a key, which takes nested values. + jsonBody +) + // portal calls one endpoint and returns the raw body after checking the // result code. GET requests carry teamID in the query, others in the body. -func (p *portalClient) portal(ctx context.Context, method, path, teamID string, values map[string]any) ([]byte, error) { +func (p *portalClient) portal(ctx context.Context, method, path, teamID string, values map[string]any, encoding bodyEncoding) ([]byte, error) { target := p.hosts.Portal + "/services-account/QH65B2/" + path var body any if method == http.MethodGet { @@ -74,18 +85,23 @@ func (p *portalClient) portal(ctx context.Context, method, path, teamID string, query.Set(name, fmt.Sprint(value)) } target += "?" + query.Encode() + } else if encoding == jsonBody { + data := map[string]any{"teamId": teamID} + for name, value := range values { + data[name] = value + } + body = data } else { - data := map[string]any{} + form := url.Values{} if teamID != "" { - data["teamId"] = teamID + form.Set("teamId", teamID) } for name, value := range values { - data[name] = value + form.Set(name, fmt.Sprint(value)) } - // A call with nothing to send has no body, as the portal's own pages - // send it: its .action endpoints answer a JSON body with 415. - if len(data) > 0 { - body = data + // A call with nothing to send has no body at all. + if len(form) > 0 { + body = form } } headers := map[string]string{"Accept": "application/json, text/plain, */*"} @@ -136,7 +152,7 @@ func result(payload []byte, out any) error { } func (p *portalClient) teams(ctx context.Context) ([]Team, error) { - payload, err := p.portal(ctx, http.MethodPost, "account/listTeams.action", "", nil) + payload, err := p.portal(ctx, http.MethodPost, "account/listTeams.action", "", nil, formBody) if err != nil { return nil, err } @@ -159,7 +175,7 @@ func (p *portalClient) keys(ctx context.Context, teamID string) ([]apns.Existing "pageNumber": page, "pageSize": keysPageSize, "sort": "name=asc", - }) + }, formBody) if err != nil { return nil, err } @@ -192,7 +208,7 @@ func (p *portalClient) createKey(ctx context.Context, teamID, name string, envir "environment": string(environment), "scope": "team", }}, - }) + }, jsonBody) if err != nil { return "", err } @@ -211,7 +227,7 @@ func (p *portalClient) createKey(ctx context.Context, teamID, name string, envir // downloadKey returns the key's .p8 contents. Apple serves them once. func (p *portalClient) downloadKey(ctx context.Context, teamID, keyID string) (string, error) { - payload, err := p.portal(ctx, http.MethodGet, "account/auth/key/download", teamID, map[string]any{"keyId": keyID}) + payload, err := p.portal(ctx, http.MethodGet, "account/auth/key/download", teamID, map[string]any{"keyId": keyID}, formBody) if err != nil { return "", err } From bb3e678dfc49d9c10dcdde08a65a51e963f5ba7c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 16:51:42 +0530 Subject: [PATCH 27/29] fix(cli): recognise Apple's current full-team message Apple now answers a team with no free APNs key slot with 'You have already reached the maximum allowed number of team scoped Keys for this service in production and sandbox environment.' All three setups looked for 'maximum allowed number of Keys', so the error came through raw instead of listing the team's keys with the --key-path advice. They now match the message with or without the words before 'Keys', and the expo helper also checks the error's own message, since apple-utils does not turn the new wording into MaxKeysCreatedError either. --- .../cli/internal/apns/appwrite/adapter_test.go | 14 +++++++++++++- templates/cli/internal/apns/appwrite/portal.go | 9 ++++++++- templates/cli/internal/apns/expo/helper/helper.js | 8 +++++++- .../cli/internal/apns/fastlane/helper/Fastfile | 3 ++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index aaeaa99f5c..5bdb64af5f 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -286,7 +286,7 @@ func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, if f.maxKeys { writeJSON(w, http.StatusOK, map[string]any{ "resultCode": 9401, - "resultString": "You%20have%20already%20reached%20the%20maximum%20allowed%20number%20of%20Keys%20for%20this%20service.", + "resultString": "You%20have%20already%20reached%20the%20maximum%20allowed%20number%20of%20team%20scoped%20Keys%20for%20this%20service%20in%20production%20and%20sandbox%20environment.", }) return @@ -646,4 +646,16 @@ func TestAnImpracticalHashcashIsNotAnswered(t *testing.T) { t.Fatal(err) } } + +func TestMaxKeysMessages(t *testing.T) { + for message, full := range map[string]bool{ + "You have already reached the maximum allowed number of Keys for this service.": true, + "You have already reached the maximum allowed number of team scoped Keys for this service in production and sandbox environment.": true, + "An unexpected error occurred.": false, + } { + if got := maxKeysMessage.MatchString(message); got != full { + t.Errorf("maxKeysMessage(%q) = %v", message, got) + } + } +} {% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/portal.go b/templates/cli/internal/apns/appwrite/portal.go index 59c19752b7..4a33de93e3 100644 --- a/templates/cli/internal/apns/appwrite/portal.go +++ b/templates/cli/internal/apns/appwrite/portal.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strings" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" @@ -22,6 +23,12 @@ const ( keysPageSize = 500 ) +// maxKeysMessage is Apple's answer when the team has no free key slot, worded +// "maximum allowed number of Keys" and, since 2026, "maximum allowed number +// of team scoped Keys for this service in production and sandbox +// environment". +var maxKeysMessage = regexp.MustCompile(`(?i)maximum allowed number of (?:[a-z ]+ )?keys`) + var ( errSessionExpired = errors.New("the Apple session expired") errMaxKeys = errors.New("maximum keys") @@ -138,7 +145,7 @@ func result(payload []byte, out any) error { if strings.Contains(message, "session has expired") { return errSessionExpired } - if strings.Contains(message, "maximum allowed number of Keys") { + if maxKeysMessage.MatchString(message) { return errMaxKeys } if message == "" { diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js index 339db2a27a..85e0319b3b 100644 --- a/templates/cli/internal/apns/expo/helper/helper.js +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -22,10 +22,16 @@ function parseArguments(argv) { return values; } +// Apple words a full team as "maximum allowed number of Keys" or, since 2026, +// "maximum allowed number of team scoped Keys ...", which apple-utils does not +// turn into MaxKeysCreatedError, so the message itself is checked too. +const maxKeysMessage = /maximum allowed number of (?:[a-z ]+ )?keys/i; + function isMaxKeysError(error) { return ( error instanceof Keys.MaxKeysCreatedError || - /maximum allowed number of Keys/.test(error?.rawDump?.resultString ?? '') + maxKeysMessage.test(error?.rawDump?.resultString ?? '') || + maxKeysMessage.test(error?.message ?? '') ); } diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile index 57ad078f83..ba6b0aa661 100644 --- a/templates/cli/internal/apns/fastlane/helper/Fastfile +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -41,7 +41,8 @@ def create_apns_key_with_spaceship(options) } ) rescue StandardError => error - raise unless error.message.include?('maximum allowed number of Keys') + # Worded with or without "team scoped" before "Keys". + raise unless error.message =~ /maximum allowed number of (?:[a-z ]+ )?keys/i keys = client.list_keys.map do |key| { id: key['keyId'], name: key['keyName'], canRevoke: key['canRevoke'] == true } From 2b546c1cf8afca1c9a156111f39ca109c2b3077b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 17:07:36 +0530 Subject: [PATCH 28/29] feat(cli): init apns --reset signs in to Apple again A saved Apple sign-in was reused until Apple expired it, and the only way to sign in again, or as another Apple ID, was to delete a file by hand. --reset signs in from scratch, two-factor included, and the new session replaces the saved one. It implies --create-key, as signing in is only for creating a key, so it is not stopped by APNs being set up already, and it cannot be combined with --key-path or --key-id. apns.Request gains Reset. The appwrite setup loads none of the saved cookies, not even the one that marks the machine as trusted. The expo helper runs apple-utils' Auth.logoutAsync, which deletes its saved session for the Apple ID, and the fastlane lane deletes spaceship's saved session for the Apple ID before signing in. --- templates/cli/internal/apns/apns.go | 5 ++- .../cli/internal/apns/appwrite/adapter.go | 5 ++- .../internal/apns/appwrite/adapter_test.go | 34 ++++++++++++++++ templates/cli/internal/apns/expo/adapter.go | 3 ++ .../cli/internal/apns/expo/adapter_test.go | 15 +++++++ .../cli/internal/apns/expo/helper/helper.js | 9 ++++- .../cli/internal/apns/fastlane/adapter.go | 3 ++ .../internal/apns/fastlane/adapter_test.go | 13 ++++++ .../internal/apns/fastlane/helper/Fastfile | 6 +++ templates/cli/internal/cmd/initpush.go | 20 ++++++++-- templates/cli/internal/cmd/initpush_test.go | 40 +++++++++++++++++++ 11 files changed, 145 insertions(+), 8 deletions(-) diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go index e2db01f368..34f6702a62 100644 --- a/templates/cli/internal/apns/apns.go +++ b/templates/cli/internal/apns/apns.go @@ -96,7 +96,10 @@ type Request struct { // SessionDir is where an adapter may keep a signed-in session between // runs, readable by the user only. SessionDir string - Asker Asker + // Reset signs in to Apple again instead of reusing a saved sign-in, and + // the new one replaces it. + Reset bool + Asker Asker // Log prints a line for the person running the command. Log func(format string, args ...any) } diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go index 657751ce6b..651f515fe1 100644 --- a/templates/cli/internal/apns/appwrite/adapter.go +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -105,8 +105,11 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli if httpClient == nil { httpClient = &http.Client{Timeout: time.Minute} } + // With Reset nothing is loaded, not even the cookie that marks this + // machine as trusted, so Apple asks for a two-factor code again; the new + // session replaces the saved one. jar := newSavedJar() - if path := sessionPath(request.SessionDir); path != "" { + if path := sessionPath(request.SessionDir); path != "" && !request.Reset { jar.load(path) } withJar := *httpClient diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go index 5bdb64af5f..8128e8be58 100644 --- a/templates/cli/internal/apns/appwrite/adapter_test.go +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -658,4 +658,38 @@ func TestMaxKeysMessages(t *testing.T) { } } } + +func TestResetSignsInAgain(t *testing.T) { + fake := newFakeApple(t) + 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) + } + + // The saved session and its trusted-machine cookie are ignored: Apple + // asks for the password check and a code again. + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}} + second, _ := newTestClient(t, fake, asker, dir, env) + second.request.Reset = true + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || !strings.Contains(requests, "/securitycode") || len(asker.asked) != 1 { + t.Errorf("asked %v, requests = %v", asker.asked, fake.requests) + } + + // The new session replaces the saved one: a normal run reuses it. + third, _ := newTestClient(t, fake, &scriptedAsker{}, dir, env) + fake.requests = nil + if _, err := third.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(fake.requests, ","), "/appleauth/") { + t.Errorf("signed in again after a reset: %v", fake.requests) + } +} {% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index 5850e472da..e072f6355a 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -112,6 +112,9 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if request.TeamID != "" { arguments = append(arguments, "--team-id", request.TeamID) } + if request.Reset { + arguments = append(arguments, "--reset", "true") + } command := exec.CommandContext(ctx, tools.node, arguments...) command.Dir = dir command.Stdin, command.Stdout, command.Stderr = a.terminal() diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go index 04dec07b07..828b71a63e 100644 --- a/templates/cli/internal/apns/expo/adapter_test.go +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -165,3 +165,18 @@ func TestCreateKeyNeedsARecentNode(t *testing.T) { t.Fatalf("missing node err = %v", err) } } + +func TestCreateKeyPassesResetToTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.Contains(helper, " --reset true ") { + t.Errorf("helper call = %s", helper) + } +} diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js index 85e0319b3b..6b992760b2 100644 --- a/templates/cli/internal/apns/expo/helper/helper.js +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -16,7 +16,7 @@ function parseArguments(argv) { values[argv[index].replace(/^--/, '')] = argv[index + 1]; } if (!values.out || !values.name) { - throw new Error('usage: helper.js [--team-id ] --name --out '); + throw new Error('usage: helper.js [--team-id ] [--reset true] --name --out '); } return values; @@ -35,7 +35,12 @@ function isMaxKeysError(error) { ); } -async function createKey({ 'team-id': teamId, name }) { +async function createKey({ 'team-id': teamId, name, reset }) { + // --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 }); const context = authState.context; diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index 5dbb2b0c75..3e9e1f2b25 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -135,6 +135,9 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if request.TeamID != "" { arguments = append(arguments, "team_id:"+request.TeamID) } + if request.Reset { + arguments = append(arguments, "reset:true") + } command := runner.command(ctx, arguments...) command.Dir = scratch command.Stdin, command.Stdout, command.Stderr = a.terminal() diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index f6c45457a2..d781813c66 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -429,3 +429,16 @@ func TestTheQuotedCdRunsInAShell(t *testing.T) { t.Errorf("cd landed in %q, %v; want %q", output, err, resolved) } } + +func TestCreateKeyPassesResetToTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if calls, _ := os.ReadFile(log); !strings.Contains(string(calls), " reset:true ") { + t.Errorf("lane call = %s", calls) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile index ba6b0aa661..c7e60b70c2 100644 --- a/templates/cli/internal/apns/fastlane/helper/Fastfile +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -21,6 +21,12 @@ end def create_apns_key_with_spaceship(options) require 'spaceship' + # 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 + File.delete(cookie) if cookie && File.exist?(cookie) + end Spaceship::Portal.login(ENV.fetch('FASTLANE_USER', nil), ENV.fetch('FASTLANE_PASSWORD', nil)) team_id = options[:team_id].to_s if team_id.empty? diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index f435951fe1..bd660acf50 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -56,6 +56,7 @@ type apnsOptions struct { teamID string bundleID string createKey bool + reset bool environment string setup string } @@ -156,7 +157,10 @@ func newInitApnsCommand() *cobra.Command { flags.BoolVar(&options.createKey, "create-key", false, "Create the APNs key by signing in with your Apple ID (experimental)") 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)") command.MarkFlagsMutuallyExclusive("create-key", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-id") command.MarkFlagsMutuallyExclusive("create-key", "key-id") return command @@ -372,6 +376,10 @@ func matchesPattern(pattern *regexp.Regexp, description string) func(string) err func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { output.Log(s.out, "Setting up APNs ...") + // --reset is for signing in again, which only creating a key does. + if options.reset { + options.createKey = true + } environment, err := apnsEnvironment(options.environment) if err != nil { @@ -399,7 +407,7 @@ func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { setUp := func(provider *messagingProvider, wanted bool) bool { return !wanted || (provider != nil && provider.Enabled) } - if setUp(production, wantProduction) && setUp(sandbox, wantSandbox) && options.keyPath == "" && !app.Flags().Force { + if setUp(production, wantProduction) && setUp(sandbox, wantSandbox) && options.keyPath == "" && !options.reset && !app.Flags().Force { 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) @@ -530,7 +538,7 @@ func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns } create = false - path, keyID, keyTeam, err := s.createApnsKey(teamID, options.setup, environment) + path, keyID, keyTeam, err := s.createApnsKey(teamID, options, environment) var maxKeys *apns.MaxKeysError if errors.As(err, &maxKeys) { output.Warn(s.out, "Apple allows two APNs keys per team, and this team has no free slot. Keys on the team:") @@ -650,8 +658,8 @@ func (a apnsAsker) Choose(question string, options []string) (int, error) { // 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, setup string, environment apns.Environment) (string, string, string, error) { - adapter, err := s.apnsAdapter(setup) +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 } @@ -661,6 +669,7 @@ func (s *pushSetup) createApnsKey(teamID, setup string, environment apns.Environ Environment: environment, AppleID: s.env(appleIDVariable), Password: s.env(applePasswordVariable), + Reset: options.reset, Asker: apnsAsker{s.prompter}, Log: func(format string, args ...any) { output.Log(s.out, format, args...) @@ -670,6 +679,9 @@ func (s *pushSetup) createApnsKey(teamID, setup string, environment apns.Environ 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 { + output.Log(s.out, "Signing in again: the saved Apple sign-in is not used (--reset).") + } if request.AppleID != "" { output.Log(s.out, "Using the Apple ID from %s.", appleIDVariable) } diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 97a23630b7..50a050081a 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -1679,3 +1679,43 @@ func TestInitApnsExplainsASignInWithoutATerminal(t *testing.T) { t.Errorf("suggests a flag --create-key rejects: %s", message) } } + +func TestInitApnsResetSignsInAgainAndCreatesAKey(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{KeyID: "FIRSTKEY12"}) + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + // Already set up, yet --reset signs in again and creates a key, without + // --create-key or --force. + setup.keyDirs = []string{t.TempDir()} + adapter.KeyID = "SECONDKEY1" + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.Requests) != 2 || adapter.Requests[0].Reset || !adapter.Requests[1].Reset { + t.Fatalf("requests = %+v", adapter.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "SECONDKEY1" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Signing in again: the saved Apple sign-in is not used (--reset).") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--reset", "--key-path", "AuthKey_KEY1234567.p8"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-path") { + t.Errorf("--reset with --key-path: %v", err) + } +} From 8813ef13257804453b743c55eaba51d91142022c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 17:20:31 +0530 Subject: [PATCH 29/29] fix(cli): ask for the Apple ID once on an expo reset With --reset and no APPWRITE_APPLE_ID, the expo setup asked for the Apple ID twice: apple-utils' Auth.logoutAsync asks for it to find the session and forgets it, and the sign-in that follows asked again. The adapter now asks once and passes it as EXPO_APPLE_ID, which both read (resolveCredentialsAsync takes the given username, then EXPO_APPLE_ID, and prompts only without either). --- templates/cli/internal/apns/expo/adapter.go | 11 +++++ .../cli/internal/apns/expo/adapter_test.go | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go index e072f6355a..7d0d3946de 100644 --- a/templates/cli/internal/apns/expo/adapter.go +++ b/templates/cli/internal/apns/expo/adapter.go @@ -22,6 +22,7 @@ import ( "path/filepath" "regexp" "strconv" + "strings" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" ) @@ -81,6 +82,16 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if log == nil { log = func(string, ...any) {} } + // 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 { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return apns.Key{}, err + } + request.AppleID = strings.TrimSpace(answer) + } dir := request.SessionDir if dir == "" { scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go index 828b71a63e..edf7ff9cbc 100644 --- a/templates/cli/internal/apns/expo/adapter_test.go +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -180,3 +180,46 @@ func TestCreateKeyPassesResetToTheHelper(t *testing.T) { t.Errorf("helper call = %s", helper) } } + +type oneAnswer struct { + answer string + asked []string +} + +func (o *oneAnswer) Ask(question string, secret bool) (string, error) { + o.asked = append(o.asked, question) + + return o.answer, nil +} + +func (o *oneAnswer) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +// A reset asks for the Apple ID once and hands it to apple-utils, whose sign +// out would otherwise ask, forget it, and leave the sign-in to ask again. +func TestResetAsksForTheAppleIDOnce(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &oneAnswer{answer: "dev@example.com"} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true, Asker: asker} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.HasSuffix(helper, "EXPO_APPLE_ID=dev@example.com") || !strings.Contains(helper, " --reset true") { + t.Errorf("helper call = %s", helper) + } + + // With the Apple ID known, nothing is asked. + known := &oneAnswer{} + request.Asker, request.AppleID = known, "dev@example.com" + if _, err := adapter.CreateKey(context.Background(), request); err != nil || len(known.asked) != 0 { + t.Errorf("asked %v, %v", known.asked, err) + } +}