Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
40347a2
feat(cli): add init apns and init push on top of init fcm
ArnabChatterjee20k Oct 5, 2026
4400a9f
test(cli): check that rotating the APNs key updates both providers in…
ArnabChatterjee20k Oct 5, 2026
c3fb235
Merge branch 'messaging-provider-cli' into messaging-provider-cli-apns
ArnabChatterjee20k Oct 5, 2026
e2b7d68
Merge branch 'messaging-provider-cli' into messaging-provider-cli-apns
ArnabChatterjee20k Oct 5, 2026
53714bc
Merge branch 'messaging-provider-cli' into messaging-provider-cli-apns
ArnabChatterjee20k Oct 5, 2026
80fbad0
feat(cli): add the apns package for APNs key setups
ArnabChatterjee20k Oct 9, 2026
0101320
feat(cli): init apns --create-key through a chosen APNs key setup
ArnabChatterjee20k Oct 9, 2026
4cfe2c0
fix(cli): use the created APNs key's ID, never --key-id
ArnabChatterjee20k Oct 9, 2026
f2d5d96
feat(cli): add Appwrite's own APNs key setup
ArnabChatterjee20k Oct 9, 2026
a02c29b
feat(cli): offer the appwrite APNs key setup in init apns
ArnabChatterjee20k Oct 9, 2026
1020be5
feat(cli): add the expo APNs key setup on @expo/apple-utils
ArnabChatterjee20k Oct 9, 2026
85da856
feat(cli): offer the expo APNs key setup in init apns
ArnabChatterjee20k Oct 9, 2026
f9692f7
feat(cli): add the fastlane APNs key setup on spaceship
ArnabChatterjee20k Oct 9, 2026
84a5d76
feat(cli): offer the fastlane APNs key setup in init apns
ArnabChatterjee20k Oct 9, 2026
b55c767
fix(cli): require fastlane 2.225.0 for the fastlane APNs key setup
ArnabChatterjee20k Oct 9, 2026
4ff11f5
fix(cli): do not count switching to a text message as a code attempt
ArnabChatterjee20k Oct 9, 2026
4ee56d8
fix(cli): send the hashcash date in UTC
ArnabChatterjee20k Oct 9, 2026
5dfc509
fix(cli): run a project's Bundler fastlane with bundle exec
ArnabChatterjee20k Oct 9, 2026
e3b53f6
test(cli): cover the expo helper call without a team
ArnabChatterjee20k Oct 9, 2026
b90e5bf
fix(cli): do not run another fastlane when the project pins one
ArnabChatterjee20k Oct 9, 2026
f900aaa
Merge branch 'messaging-provider-cli-apns-adapter-expo' into messagin…
ArnabChatterjee20k Oct 9, 2026
53d63b3
Merge branch 'messaging-provider-cli-apns-adapter-fastlane' into mess…
ArnabChatterjee20k Oct 9, 2026
b5c88ee
fix(cli): bound and cancel the hashcash search
ArnabChatterjee20k Oct 9, 2026
fa93a7b
feat(cli): download Node.js for the expo APNs key setup when missing
ArnabChatterjee20k Oct 9, 2026
765bd84
feat(cli): offer to install fastlane with Homebrew when it is missing
ArnabChatterjee20k Oct 9, 2026
f46478f
fix(cli): never remove a published Node.js runtime
ArnabChatterjee20k Oct 9, 2026
f0455a1
fix(cli): never install over a configured fastlane, and fix the Bundl…
ArnabChatterjee20k Oct 9, 2026
86be24b
fix(cli): quote the project folder in the Bundler upgrade hint
ArnabChatterjee20k Oct 9, 2026
941fda7
fix(cli): name the folder instead of quoting it on Windows
ArnabChatterjee20k Oct 9, 2026
89ea7ee
Merge branch 'messaging-provider-cli-apns-fastlane-install' into mess…
ArnabChatterjee20k Oct 9, 2026
3558c32
fix(cli): send the Apple team list request without a body
ArnabChatterjee20k Oct 9, 2026
b1fea8e
fix(cli): send Developer portal requests as forms
ArnabChatterjee20k Oct 9, 2026
bb3e678
fix(cli): recognise Apple's current full-team message
ArnabChatterjee20k Oct 9, 2026
2b546c1
feat(cli): init apns --reset signs in to Apple again
ArnabChatterjee20k Oct 9, 2026
8813ef1
fix(cli): ask for the Apple ID once on an expo reset
ArnabChatterjee20k Oct 9, 2026
e7cdbea
Merge pull request #1999 from appwrite/messaging-provider-cli-apns-reset
ArnabChatterjee20k Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/SDK/Language/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,106 @@ 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/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/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' => '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',
'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',
Expand Down
196 changes: 196 additions & 0 deletions templates/cli/internal/apns/apns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// 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
// 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)
}

// 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(), ", "))
}
Loading
Loading