diff --git a/bskyweb/cmd/bskyweb/passdata.go b/bskyweb/cmd/bskyweb/passdata.go new file mode 100644 index 0000000000..573ce64c78 --- /dev/null +++ b/bskyweb/cmd/bskyweb/passdata.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "strings" +) + +var themeBgRGB = map[string]string{ + "dawn": "rgb(255, 109, 190)", + "day": "rgb(117, 175, 255)", + "dusk": "rgb(177, 90, 162)", + "night": "rgb(0, 21, 51)", +} + +func CoerceTheme(s string) string { + switch strings.ToLower(s) { + case "dawn", "day", "dusk", "night": + return strings.ToLower(s) + default: + return "day" + } +} + +func ThemeBackgroundRGB(theme string) string { + if v, ok := themeBgRGB[theme]; ok { + return v + } + return themeBgRGB["day"] +} + +type passField struct { + Key string `json:"key"` + Label string `json:"label"` + Value string `json:"value"` +} + +type storeCardFields struct { + PrimaryFields []passField `json:"primaryFields"` + SecondaryFields []passField `json:"secondaryFields"` + BackFields []passField `json:"backFields"` +} + +type barcode struct { + Format string `json:"format"` + Message string `json:"message"` + MessageEncoding string `json:"messageEncoding"` + AltText string `json:"altText"` +} + +type pkPass struct { + FormatVersion int `json:"formatVersion"` + PassTypeIdentifier string `json:"passTypeIdentifier"` + SerialNumber string `json:"serialNumber"` + TeamIdentifier string `json:"teamIdentifier"` + OrganizationName string `json:"organizationName"` + Description string `json:"description"` + LogoText string `json:"logoText"` + ForegroundColor string `json:"foregroundColor"` + LabelColor string `json:"labelColor"` + BackgroundColor string `json:"backgroundColor"` + StoreCard storeCardFields `json:"storeCard"` + Barcodes []barcode `json:"barcodes"` +} + +const passTypeIdentifier = "pass.app.bsky.invite" + +func BuildPassJSON(did, handle, theme, teamID string) ([]byte, error) { + theme = CoerceTheme(theme) + profileURL := "https://bsky.app/profile/" + handle + atHandle := "@" + handle + p := pkPass{ + FormatVersion: 1, + PassTypeIdentifier: passTypeIdentifier, + SerialNumber: did + "-" + theme + "-v1", + TeamIdentifier: teamID, + OrganizationName: "Bluesky", + Description: "Bluesky invite - " + atHandle, + LogoText: "Bluesky", + ForegroundColor: "rgb(255, 255, 255)", + LabelColor: "rgb(255, 255, 255)", + BackgroundColor: ThemeBackgroundRGB(theme), + StoreCard: storeCardFields{ + PrimaryFields: []passField{{Key: "handle", Label: "Handle", Value: atHandle}}, + SecondaryFields: []passField{{Key: "url", Label: "Profile", Value: "bsky.app/profile/" + handle}}, + BackFields: []passField{ + {Key: "about", Label: "About", Value: "Scan the QR to open this profile in any browser, or share the URL below."}, + {Key: "url2", Label: "Profile URL", Value: profileURL}, + }, + }, + Barcodes: []barcode{{ + Format: "PKBarcodeFormatQR", + Message: profileURL, + MessageEncoding: "iso-8859-1", + AltText: atHandle, + }}, + } + return json.MarshalIndent(p, "", " ") +} diff --git a/bskyweb/cmd/bskyweb/passdata_test.go b/bskyweb/cmd/bskyweb/passdata_test.go new file mode 100644 index 0000000000..519de1f1c5 --- /dev/null +++ b/bskyweb/cmd/bskyweb/passdata_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestCoerceTheme(t *testing.T) { + cases := map[string]string{ + "dawn": "dawn", "day": "day", "dusk": "dusk", "night": "night", + "": "day", + "garbage": "day", + "DAY": "day", // case-insensitive + } + for in, want := range cases { + if got := CoerceTheme(in); got != want { + t.Errorf("CoerceTheme(%q) = %q, want %q", in, got, want) + } + } +} + +func TestThemeBackgroundRGB(t *testing.T) { + cases := map[string]string{ + "dawn": "rgb(255, 109, 190)", + "day": "rgb(117, 175, 255)", + "dusk": "rgb(177, 90, 162)", + "night": "rgb(0, 21, 51)", + } + for in, want := range cases { + if got := ThemeBackgroundRGB(in); got != want { + t.Errorf("ThemeBackgroundRGB(%q) = %q, want %q", in, got, want) + } + } +} + +func TestBuildPassJSON_Golden(t *testing.T) { + for _, theme := range []string{"dawn", "day", "dusk", "night"} { + t.Run(theme, func(t *testing.T) { + got, err := BuildPassJSON("did:plc:abc123", "alice.bsky.social", theme, "TEAMID00") + if err != nil { + t.Fatalf("build: %v", err) + } + golden := filepath.Join("testdata", "pass-"+theme+".golden.json") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v", err) + } + var gotMap, wantMap map[string]any + _ = json.Unmarshal(got, &gotMap) + _ = json.Unmarshal(want, &wantMap) + if !mapsEqual(gotMap, wantMap) { + t.Errorf("pass.json drift; rerun with UPDATE_GOLDEN=1 to update.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + }) + } +} + +func mapsEqual(a, b map[string]any) bool { + ab, _ := json.Marshal(a) + bb, _ := json.Marshal(b) + return string(ab) == string(bb) +} diff --git a/bskyweb/cmd/bskyweb/testdata/pass-dawn.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-dawn.golden.json new file mode 100644 index 0000000000..83fcf682cc --- /dev/null +++ b/bskyweb/cmd/bskyweb/testdata/pass-dawn.golden.json @@ -0,0 +1,48 @@ +{ + "formatVersion": 1, + "passTypeIdentifier": "pass.app.bsky.invite", + "serialNumber": "did:plc:abc123-dawn-v1", + "teamIdentifier": "TEAMID00", + "organizationName": "Bluesky", + "description": "Bluesky invite - @alice.bsky.social", + "logoText": "Bluesky", + "foregroundColor": "rgb(255, 255, 255)", + "labelColor": "rgb(255, 255, 255)", + "backgroundColor": "rgb(255, 109, 190)", + "storeCard": { + "primaryFields": [ + { + "key": "handle", + "label": "Handle", + "value": "@alice.bsky.social" + } + ], + "secondaryFields": [ + { + "key": "url", + "label": "Profile", + "value": "bsky.app/profile/alice.bsky.social" + } + ], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR to open this profile in any browser, or share the URL below." + }, + { + "key": "url2", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "barcodes": [ + { + "format": "PKBarcodeFormatQR", + "message": "https://bsky.app/profile/alice.bsky.social", + "messageEncoding": "iso-8859-1", + "altText": "@alice.bsky.social" + } + ] +} \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/testdata/pass-day.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-day.golden.json new file mode 100644 index 0000000000..7ae46139a0 --- /dev/null +++ b/bskyweb/cmd/bskyweb/testdata/pass-day.golden.json @@ -0,0 +1,48 @@ +{ + "formatVersion": 1, + "passTypeIdentifier": "pass.app.bsky.invite", + "serialNumber": "did:plc:abc123-day-v1", + "teamIdentifier": "TEAMID00", + "organizationName": "Bluesky", + "description": "Bluesky invite - @alice.bsky.social", + "logoText": "Bluesky", + "foregroundColor": "rgb(255, 255, 255)", + "labelColor": "rgb(255, 255, 255)", + "backgroundColor": "rgb(117, 175, 255)", + "storeCard": { + "primaryFields": [ + { + "key": "handle", + "label": "Handle", + "value": "@alice.bsky.social" + } + ], + "secondaryFields": [ + { + "key": "url", + "label": "Profile", + "value": "bsky.app/profile/alice.bsky.social" + } + ], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR to open this profile in any browser, or share the URL below." + }, + { + "key": "url2", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "barcodes": [ + { + "format": "PKBarcodeFormatQR", + "message": "https://bsky.app/profile/alice.bsky.social", + "messageEncoding": "iso-8859-1", + "altText": "@alice.bsky.social" + } + ] +} \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/testdata/pass-dusk.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-dusk.golden.json new file mode 100644 index 0000000000..c85343767d --- /dev/null +++ b/bskyweb/cmd/bskyweb/testdata/pass-dusk.golden.json @@ -0,0 +1,48 @@ +{ + "formatVersion": 1, + "passTypeIdentifier": "pass.app.bsky.invite", + "serialNumber": "did:plc:abc123-dusk-v1", + "teamIdentifier": "TEAMID00", + "organizationName": "Bluesky", + "description": "Bluesky invite - @alice.bsky.social", + "logoText": "Bluesky", + "foregroundColor": "rgb(255, 255, 255)", + "labelColor": "rgb(255, 255, 255)", + "backgroundColor": "rgb(177, 90, 162)", + "storeCard": { + "primaryFields": [ + { + "key": "handle", + "label": "Handle", + "value": "@alice.bsky.social" + } + ], + "secondaryFields": [ + { + "key": "url", + "label": "Profile", + "value": "bsky.app/profile/alice.bsky.social" + } + ], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR to open this profile in any browser, or share the URL below." + }, + { + "key": "url2", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "barcodes": [ + { + "format": "PKBarcodeFormatQR", + "message": "https://bsky.app/profile/alice.bsky.social", + "messageEncoding": "iso-8859-1", + "altText": "@alice.bsky.social" + } + ] +} \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/testdata/pass-night.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-night.golden.json new file mode 100644 index 0000000000..1b19127411 --- /dev/null +++ b/bskyweb/cmd/bskyweb/testdata/pass-night.golden.json @@ -0,0 +1,48 @@ +{ + "formatVersion": 1, + "passTypeIdentifier": "pass.app.bsky.invite", + "serialNumber": "did:plc:abc123-night-v1", + "teamIdentifier": "TEAMID00", + "organizationName": "Bluesky", + "description": "Bluesky invite - @alice.bsky.social", + "logoText": "Bluesky", + "foregroundColor": "rgb(255, 255, 255)", + "labelColor": "rgb(255, 255, 255)", + "backgroundColor": "rgb(0, 21, 51)", + "storeCard": { + "primaryFields": [ + { + "key": "handle", + "label": "Handle", + "value": "@alice.bsky.social" + } + ], + "secondaryFields": [ + { + "key": "url", + "label": "Profile", + "value": "bsky.app/profile/alice.bsky.social" + } + ], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR to open this profile in any browser, or share the URL below." + }, + { + "key": "url2", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "barcodes": [ + { + "format": "PKBarcodeFormatQR", + "message": "https://bsky.app/profile/alice.bsky.social", + "messageEncoding": "iso-8859-1", + "altText": "@alice.bsky.social" + } + ] +} \ No newline at end of file