diff --git a/internal/notarize/notarize.go b/internal/notarize/notarize.go new file mode 100644 index 0000000..c335a7b --- /dev/null +++ b/internal/notarize/notarize.go @@ -0,0 +1,92 @@ +package notarize + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/leonmika/wails-release/internal/runner" +) + +// APIKey is the App Store Connect API key credential set. +type APIKey struct { + KeyPath string + KeyID string + IssuerID string +} + +// AppleID is the legacy Apple ID + app-specific password credential set. +type AppleID struct { + Username string + Password string + TeamID string +} + +// Opts configures a notarization submission. Exactly one of APIKey or +// AppleID must be set. +type Opts struct { + ZipPath string + APIKey *APIKey + AppleID *AppleID +} + +// Submit ships ZipPath to Apple's notary service and waits for the result. +// Rejected submissions cause the per-submission log to be fetched and +// embedded in the returned error. +func Submit(ctx context.Context, r runner.Runner, opts Opts) error { + if (opts.APIKey == nil) == (opts.AppleID == nil) { + return fmt.Errorf("notarize: exactly one of APIKey or AppleID must be set") + } + credArgs := credentialArgs(opts) + + args := append([]string{"notarytool", "submit", opts.ZipPath}, credArgs...) + args = append(args, "--wait", "--output-format", "json") + + out, err := r.Run(ctx, runner.Spec{Name: "xcrun", Args: args}) + if err != nil { + return fmt.Errorf("notarytool submit: %w", err) + } + + var result struct { + ID string `json:"id"` + Status string `json:"status"` + } + if jerr := json.Unmarshal(out, &result); jerr != nil { + return fmt.Errorf("notarytool submit: parse output: %w (raw: %s)", jerr, string(out)) + } + if result.Status == "Accepted" { + return nil + } + + logOut, _ := r.Run(ctx, runner.Spec{ + Name: "xcrun", + Args: append([]string{"notarytool", "log", result.ID}, credArgs...), + }) + return fmt.Errorf("notarization %s: %s\n%s", result.Status, result.ID, string(logOut)) +} + +// Staple runs `xcrun stapler staple` against the bundle. +func Staple(ctx context.Context, r runner.Runner, appPath string) error { + if _, err := r.Run(ctx, runner.Spec{ + Name: "xcrun", + Args: []string{"stapler", "staple", appPath}, + }); err != nil { + return fmt.Errorf("stapler staple: %w", err) + } + return nil +} + +func credentialArgs(opts Opts) []string { + if opts.APIKey != nil { + return []string{ + "--key", opts.APIKey.KeyPath, + "--key-id", opts.APIKey.KeyID, + "--issuer", opts.APIKey.IssuerID, + } + } + return []string{ + "--apple-id", opts.AppleID.Username, + "--password", opts.AppleID.Password, + "--team-id", opts.AppleID.TeamID, + } +} diff --git a/internal/notarize/notarize_test.go b/internal/notarize/notarize_test.go new file mode 100644 index 0000000..aa81d99 --- /dev/null +++ b/internal/notarize/notarize_test.go @@ -0,0 +1,120 @@ +package notarize_test + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/leonmika/wails-release/internal/notarize" + "github.com/leonmika/wails-release/internal/runner" +) + +func TestSubmit_APIKeyArgs(t *testing.T) { + f := &runner.Fake{} + f.On("xcrun", nil).Return([]byte(`{"id":"sub-1","status":"Accepted"}`), nil) + + err := notarize.Submit(context.Background(), f, notarize.Opts{ + ZipPath: "/dist/MyApp.app.zip", + APIKey: ¬arize.APIKey{KeyPath: "/tmp/key.p8", KeyID: "ABCD1234", IssuerID: "iss"}, + }) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + want := []string{ + "notarytool", "submit", "/dist/MyApp.app.zip", + "--key", "/tmp/key.p8", + "--key-id", "ABCD1234", + "--issuer", "iss", + "--wait", + "--output-format", "json", + } + if !reflect.DeepEqual(f.Calls[0].Args, want) { + t.Fatalf("args got %v want %v", f.Calls[0].Args, want) + } +} + +func TestSubmit_AppleIDArgs(t *testing.T) { + f := &runner.Fake{} + f.On("xcrun", nil).Return([]byte(`{"id":"1","status":"Accepted"}`), nil) + + err := notarize.Submit(context.Background(), f, notarize.Opts{ + ZipPath: "/dist/MyApp.app.zip", + AppleID: ¬arize.AppleID{Username: "u@x", Password: "pw", TeamID: "TEAM1234"}, + }) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + want := []string{ + "notarytool", "submit", "/dist/MyApp.app.zip", + "--apple-id", "u@x", + "--password", "pw", + "--team-id", "TEAM1234", + "--wait", + "--output-format", "json", + } + if !reflect.DeepEqual(f.Calls[0].Args, want) { + t.Fatalf("args got %v want %v", f.Calls[0].Args, want) + } +} + +func TestSubmit_RejectionFetchesLogAndReturnsError(t *testing.T) { + f := &runner.Fake{} + // First call: submit returns Invalid status with id. + f.On("xcrun", []string{ + "notarytool", "submit", "/dist/MyApp.app.zip", + "--key", "/tmp/key.p8", "--key-id", "K", "--issuer", "I", + "--wait", "--output-format", "json", + }).Return([]byte(`{"id":"sub-1","status":"Invalid"}`), nil) + + // Second call: log fetch. + f.On("xcrun", []string{ + "notarytool", "log", "sub-1", + "--key", "/tmp/key.p8", "--key-id", "K", "--issuer", "I", + }).Return([]byte(`{"issues":[{"message":"missing entitlement"}]}`), nil) + + err := notarize.Submit(context.Background(), f, notarize.Opts{ + ZipPath: "/dist/MyApp.app.zip", + APIKey: ¬arize.APIKey{KeyPath: "/tmp/key.p8", KeyID: "K", IssuerID: "I"}, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "missing entitlement") { + t.Fatalf("expected log content in error, got %v", err) + } +} + +func TestSubmit_NoCredentialsErrors(t *testing.T) { + err := notarize.Submit(context.Background(), &runner.Fake{}, notarize.Opts{ZipPath: "/x"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestSubmit_ToolFailureWraps(t *testing.T) { + f := &runner.Fake{} + f.On("xcrun", nil).Return(nil, errors.New("network down")) + err := notarize.Submit(context.Background(), f, notarize.Opts{ + ZipPath: "/x", + APIKey: ¬arize.APIKey{KeyPath: "/k", KeyID: "K", IssuerID: "I"}, + }) + if err == nil { + t.Fatal("expected error") + } +} + +func TestStaple_BuildsCorrectArgs(t *testing.T) { + f := &runner.Fake{} + f.On("xcrun", nil).Return(nil, nil) + + err := notarize.Staple(context.Background(), f, "/build/MyApp.app") + if err != nil { + t.Fatalf("unexpected: %v", err) + } + want := []string{"stapler", "staple", "/build/MyApp.app"} + if !reflect.DeepEqual(f.Calls[0].Args, want) { + t.Fatalf("args got %v want %v", f.Calls[0].Args, want) + } +}