@stacksjs/browser-extension 0.70.144 → 0.70.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,7 @@ buddy extension:chrome:publish --upload-only
31
31
  buddy extension:firefox:publish # build, submit, sign through AMO
32
32
  buddy extension:safari:provision # register Bundle IDs + check app record
33
33
  buddy extension:safari:publish
34
+ buddy extension:safari:submit # submit an already-uploaded version
34
35
  ```
35
36
 
36
37
  Chrome reads `CHROME_WEB_STORE_SERVICE_ACCOUNT_PATH` (or
@@ -79,23 +80,32 @@ buddy extension:safari:provision # register both Bundle IDs + check the app reco
79
80
  buddy extension:safari:app # build configured macOS/iOS apps
80
81
  buddy extension:safari:app --platform ios # build for iPhone/iPad Simulator
81
82
  buddy extension:safari:publish # archive + upload every configured Apple platform
83
+ buddy extension:safari:submit # sync listing metadata and submit existing builds
82
84
  ```
83
85
 
84
86
  Set `safariBundleId` in the config (the appex gets `<safariBundleId>.Extension`)
85
87
  and `safariTeamId` to the Apple Developer team used for signing. Set
86
88
  `safariPlatforms: ['macos', 'ios']` for a universal extension; iOS covers both
87
89
  iPhone and iPad. Stacks generates the current Apple-supported universal Xcode
88
- project from the same built extension, archives both platforms, and uploads
89
- both to the same App Store Connect app record. Publishing
90
+ project from the same built extension, archives both platforms, uploads them
91
+ to the same App Store Connect app record, waits for Apple processing, and
92
+ selects each processed build on its matching App Store version. Publishing
90
93
  reads `APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_API_ISSUER_ID`, and
91
94
  `APP_STORE_CONNECT_API_KEY_PATH` from the environment. Run with
92
95
  `--validate-only` to exercise Apple's validation without uploading a build.
93
- and list any build output that is not part of the extension (marketing pages,
96
+ List any build output that is not part of the extension (marketing pages,
94
97
  etc.) in `safariExclude` so it stays out of the appex. The scaffold mirrors
95
98
  what `xcrun safari-web-extension-converter` generates, so day-to-day work
96
99
  never needs the converter; `--signed` builds need an Apple Development
97
100
  identity selected in Xcode.
98
101
 
102
+ When `safariAppStore` is configured, publishing also synchronizes the app
103
+ description, category, content-rights declaration, pricing, age rating,
104
+ territory availability, review contact, export compliance, and required
105
+ screenshots. With `submitForReview: true`, the macOS and iOS versions are then
106
+ submitted independently to App Review. The workflow is resumable and keeps an
107
+ existing active review submission intact.
108
+
99
109
  For device testing, install the generated iOS app from Xcode, then enable the
100
110
  extension in Settings > Apps > Safari > Extensions. The iOS target supports
101
111
  iPhone and iPad from iOS 15 onward; the extension's configured Safari minimum
@@ -117,6 +127,27 @@ export default defineExtension({
117
127
  safariTeamId: 'TEAM123456',
118
128
  safariPlatforms: ['macos', 'ios'], // iOS includes iPhone and iPad
119
129
  safariAppCategory: 'public.app-category.utilities',
130
+ safariAppStore: {
131
+ subtitle: 'Fast, private blocking',
132
+ privacyPolicyUrl: 'https://example.com/privacy',
133
+ description: 'A private Safari extension.',
134
+ keywords: 'privacy,blocking',
135
+ supportUrl: 'https://example.com/support',
136
+ copyright: '2026 Example',
137
+ primaryCategory: 'UTILITIES',
138
+ contentRightsDeclaration: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',
139
+ price: '0',
140
+ reviewContact: {
141
+ firstName: 'App', lastName: 'Reviewer',
142
+ phone: '+1 555-555-0100', email: 'review@example.com',
143
+ },
144
+ screenshots: {
145
+ APP_DESKTOP: ['resources/store/macos.png'],
146
+ APP_IPHONE_67: ['resources/store/iphone.png'],
147
+ APP_IPAD_PRO_3GEN_129: ['resources/store/ipad.png'],
148
+ },
149
+ submitForReview: true,
150
+ },
120
151
  targets: ['chrome', 'firefox'],
121
152
 
122
153
  background: 'src/background/index.ts',
@@ -2,6 +2,8 @@ import type { AppStoreConnectAuth } from './safari';
2
2
  import type { ExtensionConfig, SafariPlatform } from './types';
3
3
  /** Generate the short-lived ES256 team token required by App Store Connect. */
4
4
  export declare function appStoreConnectToken(auth: Required<AppStoreConnectAuth>, now?: unknown): string;
5
+ /** Wait for the uploaded binaries to process, then select them for their App Store versions. */
6
+ export declare function attachSafariBuilds(client: AppStoreConnectClient, appId: string, versions: SafariAppStoreVersionResult[], buildNumber: string, options?: AttachSafariBuildsOptions): Promise<SafariBuildAttachmentResult[]>;
5
7
  /**
6
8
  * Register the explicit container and extension Bundle IDs required by a
7
9
  * Safari Web Extension, then check for the manually-created App Store Connect
@@ -12,6 +14,7 @@ export declare interface AppStoreConnectResource<T extends Record<string, unknow
12
14
  type: string
13
15
  id: string
14
16
  attributes: T
17
+ relationships?: Record<string, { data?: { type: string, id: string } | null }>
15
18
  }
16
19
  export declare interface BundleIdAttributes extends Record<string, unknown> {
17
20
  identifier: string
@@ -30,6 +33,23 @@ export declare interface AppStoreVersionAttributes extends Record<string, unknow
30
33
  versionString: string
31
34
  appStoreState: string
32
35
  }
36
+ export declare interface BuildAttributes extends Record<string, unknown> {
37
+ version: string
38
+ uploadedDate: string
39
+ expired: boolean
40
+ processingState: string
41
+ }
42
+ export declare interface PreReleaseVersionAttributes extends Record<string, unknown> {
43
+ version: string
44
+ platform: AppStoreVersionPlatform
45
+ }
46
+ export declare interface AppStoreBuildResult {
47
+ id: string
48
+ buildNumber: string
49
+ version: string
50
+ platform: AppStoreVersionPlatform
51
+ processingState: string
52
+ }
33
53
  export declare interface AppStoreConnectClientOptions extends AppStoreConnectAuth {
34
54
  baseUrl?: string
35
55
  fetch?: typeof globalThis.fetch
@@ -42,6 +62,16 @@ export declare interface SafariAppStoreVersionResult {
42
62
  updated: boolean
43
63
  id: string
44
64
  }
65
+ export declare interface SafariBuildAttachmentResult {
66
+ platform: SafariPlatform
67
+ versionId: string
68
+ buildId: string
69
+ buildNumber: string
70
+ }
71
+ export declare interface AttachSafariBuildsOptions {
72
+ timeoutMs?: number
73
+ pollIntervalMs?: number
74
+ }
45
75
  export declare interface SafariProvisionOptions extends AppStoreConnectClientOptions {
46
76
  checkOnly?: boolean
47
77
  platform?: BundleIdPlatform
@@ -59,6 +89,8 @@ export type AppStoreVersionPlatform = 'IOS' | 'MAC_OS';
59
89
  /** Minimal official App Store Connect client for Safari provisioning checks. */
60
90
  export declare class AppStoreConnectClient {
61
91
  constructor(options?: AppStoreConnectClientOptions);
92
+ request<T>(path: string, init?: RequestInit): Promise<T>;
93
+ upload(url: string, init: RequestInit): Promise<void>;
62
94
  findBundleId(identifier: string): Promise<AppStoreConnectResource<BundleIdAttributes> | undefined>;
63
95
  registerBundleId(identifier: string, name: string, platform?: BundleIdPlatform): Promise<AppStoreConnectResource<BundleIdAttributes>>;
64
96
  ensureBundleId(identifier: string, name: string, options?: { checkOnly?: boolean, platform?: BundleIdPlatform }): Promise<{ bundleId?: AppStoreConnectResource<BundleIdAttributes>, created: boolean }>;
@@ -66,4 +98,6 @@ export declare class AppStoreConnectClient {
66
98
  listAppStoreVersions(appId: string): Promise<Array<AppStoreConnectResource<AppStoreVersionAttributes>>>;
67
99
  createAppStoreVersion(appId: string, platform: AppStoreVersionPlatform, versionString: string): Promise<AppStoreConnectResource<AppStoreVersionAttributes>>;
68
100
  updateAppStoreVersion(versionId: string, versionString: string): Promise<AppStoreConnectResource<AppStoreVersionAttributes>>;
101
+ listBuilds(appId: string, buildNumber: string): Promise<AppStoreBuildResult[]>;
102
+ attachBuild(versionId: string, buildId: string): Promise<void>;
69
103
  }
@@ -2,6 +2,13 @@ import { createPrivateKey, sign } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolveAppStoreConnectAuth } from "./safari";
4
4
  const appStoreConnectBaseUrl = "https://api.appstoreconnect.apple.com/v1";
5
+ function apiErrorMessages(error) {
6
+ const messages = [error.detail ?? error.title ?? error.code].filter((message) => Boolean(message));
7
+ for (const associated of Object.values(error.meta?.associatedErrors ?? {}))
8
+ for (const nested of associated)
9
+ messages.push(...apiErrorMessages(nested));
10
+ return messages;
11
+ }
5
12
  function base64urlJson(value) {
6
13
  return Buffer.from(JSON.stringify(value)).toString("base64url");
7
14
  }
@@ -34,7 +41,7 @@ export class AppStoreConnectClient {
34
41
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
35
42
  }
36
43
  async request(path, init = {}) {
37
- const response = await this.fetcher(`${this.baseUrl}${path}`, {
44
+ const url = /^https?:\/\//.test(path) ? path : /^\/v\d+\//.test(path) ? `${new URL(this.baseUrl).origin}${path}` : `${this.baseUrl}${path}`, response = await this.fetcher(url, {
38
45
  ...init,
39
46
  headers: {
40
47
  Accept: "application/json",
@@ -44,11 +51,18 @@ export class AppStoreConnectClient {
44
51
  }
45
52
  });
46
53
  if (!response.ok) {
47
- const details = (await response.json().catch(() => ({}))).errors?.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean).join("; ");
54
+ const body = await response.json().catch(() => ({})), details = [...new Set(body.errors?.flatMap(apiErrorMessages) ?? [])].join("; ");
48
55
  throw Error(`[browser-extension] App Store Connect ${init.method ?? "GET"} ${path} failed (${response.status})${details ? `: ${details}` : ""}`);
49
56
  }
57
+ if (response.status === 204)
58
+ return;
50
59
  return await response.json();
51
60
  }
61
+ async upload(url, init) {
62
+ const response = await this.fetcher(url, init);
63
+ if (!response.ok)
64
+ throw Error(`[browser-extension] App Store Connect asset upload failed (${response.status})`);
65
+ }
52
66
  async findBundleId(identifier) {
53
67
  const query = new URLSearchParams({ "filter[identifier]": identifier });
54
68
  return (await this.request(`/bundleIds?${query}`)).data.find((bundleId) => bundleId.attributes.identifier === identifier);
@@ -106,6 +120,34 @@ export class AppStoreConnectClient {
106
120
  })
107
121
  })).data;
108
122
  }
123
+ async listBuilds(appId, buildNumber) {
124
+ const query = new URLSearchParams({
125
+ "filter[app]": appId,
126
+ "filter[version]": buildNumber,
127
+ include: "preReleaseVersion",
128
+ limit: "20",
129
+ "fields[builds]": "version,uploadedDate,expired,processingState,preReleaseVersion",
130
+ "fields[preReleaseVersions]": "version,platform"
131
+ }), response = await this.request(`/builds?${query}`), preReleaseVersions = new Map((response.included ?? []).filter((item) => item.type === "preReleaseVersions").map((item) => [item.id, item.attributes]));
132
+ return response.data.flatMap((build) => {
133
+ const preReleaseVersionId = build.relationships?.preReleaseVersion?.data?.id, preReleaseVersion = preReleaseVersionId ? preReleaseVersions.get(preReleaseVersionId) : void 0;
134
+ if (!preReleaseVersion)
135
+ return [];
136
+ return [{
137
+ id: build.id,
138
+ buildNumber: build.attributes.version,
139
+ version: preReleaseVersion.version,
140
+ platform: preReleaseVersion.platform,
141
+ processingState: build.attributes.processingState
142
+ }];
143
+ });
144
+ }
145
+ async attachBuild(versionId, buildId) {
146
+ await this.request(`/appStoreVersions/${versionId}/relationships/build`, {
147
+ method: "PATCH",
148
+ body: JSON.stringify({ data: { type: "builds", id: buildId } })
149
+ });
150
+ }
109
151
  }
110
152
  const editableVersionStates = new Set([
111
153
  "PREPARE_FOR_SUBMISSION",
@@ -116,6 +158,37 @@ const editableVersionStates = new Set([
116
158
  function appStorePlatform(platform) {
117
159
  return platform === "ios" ? "IOS" : "MAC_OS";
118
160
  }
161
+ export async function attachSafariBuilds(client, appId, versions, buildNumber, options = {}) {
162
+ const timeoutMs = options.timeoutMs ?? 1200000, pollIntervalMs = options.pollIntervalMs ?? 15000, deadline = Date.now() + timeoutMs, pending = new Map(versions.map((version) => [appStorePlatform(version.platform), version]));
163
+ while (!0) {
164
+ const builds = await client.listBuilds(appId, buildNumber), attachments = [];
165
+ for (const [platform, version] of pending) {
166
+ const build = builds.find((item) => item.platform === platform && item.version === version.version);
167
+ if (!build)
168
+ continue;
169
+ if (build.processingState === "FAILED" || build.processingState === "INVALID")
170
+ throw Error(`[browser-extension] Safari ${version.platform} build ${buildNumber} failed App Store Connect processing (${build.processingState})`);
171
+ if (build.processingState !== "VALID")
172
+ continue;
173
+ attachments.push({
174
+ platform: version.platform,
175
+ versionId: version.id,
176
+ buildId: build.id,
177
+ buildNumber
178
+ });
179
+ }
180
+ if (attachments.length === pending.size) {
181
+ for (const attachment of attachments)
182
+ await client.attachBuild(attachment.versionId, attachment.buildId);
183
+ return attachments;
184
+ }
185
+ if (Date.now() >= deadline) {
186
+ const waiting = [...pending.values()].map((item) => item.platform).join(", ");
187
+ throw Error(`[browser-extension] timed out waiting for Safari ${waiting} build ${buildNumber} to finish App Store Connect processing`);
188
+ }
189
+ await Bun.sleep(pollIntervalMs);
190
+ }
191
+ }
119
192
  async function ensureConfiguredAppStoreVersions(client, appId, platforms, version) {
120
193
  const versions = await client.listAppStoreVersions(appId), results = [];
121
194
  for (const platform of [...new Set(platforms)]) {
@@ -0,0 +1,27 @@
1
+ import { AppStoreConnectClient } from './app-store-connect';
2
+ import type { AppStoreConnectClientOptions } from './app-store-connect';
3
+ import type { ExtensionConfig, SafariPlatform } from './types';
4
+ /** Synchronize a Safari listing and optionally submit all configured platforms to App Review. */
5
+ export declare function prepareSafariAppStoreSubmission(client: AppStoreConnectClient, config: ExtensionConfig, appId: string, versions: SafariStoreVersion[], options?: SafariStoreSubmissionOptions): Promise<SafariStoreSubmissionResult>;
6
+ /** Prepare and submit an already-uploaded Safari version without rebuilding its binary. */
7
+ export declare function submitSafariAppStore(config: ExtensionConfig, options: SubmitSafariAppStoreOptions): Promise<SafariStoreSubmissionResult>;
8
+ declare interface SafariStoreVersion {
9
+ platform: SafariPlatform
10
+ id: string
11
+ buildId?: string
12
+ }
13
+ export declare interface SafariStoreSubmissionOptions {
14
+ cwd?: string
15
+ submit?: boolean
16
+ screenshotTimeoutMs?: number
17
+ screenshotPollIntervalMs?: number
18
+ }
19
+ export declare interface SafariStoreSubmissionResult {
20
+ appId: string
21
+ versions: Array<{ platform: SafariPlatform, versionId: string, localizationId: string }>
22
+ reviewSubmissionIds: string[]
23
+ }
24
+ export declare interface SubmitSafariAppStoreOptions extends AppStoreConnectClientOptions, SafariStoreSubmissionOptions {
25
+ version: string
26
+ platforms?: SafariPlatform[]
27
+ }
@@ -0,0 +1,443 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename, resolve } from "node:path";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { AppStoreConnectClient } from "./app-store-connect";
5
+ const frequencyFields = [
6
+ "alcoholTobaccoOrDrugUseOrReferences",
7
+ "contests",
8
+ "gamblingSimulated",
9
+ "gunsOrOtherWeapons",
10
+ "medicalOrTreatmentInformation",
11
+ "profanityOrCrudeHumor",
12
+ "sexualContentGraphicAndNudity",
13
+ "sexualContentOrNudity",
14
+ "horrorOrFearThemes",
15
+ "matureOrSuggestiveThemes",
16
+ "violenceCartoonOrFantasy",
17
+ "violenceRealisticProlongedGraphicOrSadistic",
18
+ "violenceRealistic"
19
+ ], booleanFields = [
20
+ "advertising",
21
+ "gambling",
22
+ "healthOrWellnessTopics",
23
+ "lootBox",
24
+ "messagingAndChat",
25
+ "parentalControls",
26
+ "ageAssurance",
27
+ "socialMedia",
28
+ "socialMediaAgeRestricted",
29
+ "unrestrictedWebAccess",
30
+ "userGeneratedContent"
31
+ ], editableVersionStates = new Set(["PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"]);
32
+ function ageRatingAttributes(config = {}) {
33
+ const attributes = {
34
+ ageRatingOverrideV2: "NONE",
35
+ koreaAgeRatingOverride: "NONE"
36
+ };
37
+ for (const field of frequencyFields)
38
+ attributes[field] = config[field] ?? "NONE";
39
+ for (const field of booleanFields)
40
+ attributes[field] = config[field] ?? !1;
41
+ return attributes;
42
+ }
43
+ function applePlatform(platform) {
44
+ return platform === "ios" ? "IOS" : "MAC_OS";
45
+ }
46
+ function displayTypesForPlatform(platform) {
47
+ return platform === "macos" ? ["APP_DESKTOP"] : ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129"];
48
+ }
49
+ async function updateAppInfo(client, appId, config) {
50
+ await client.request(`/apps/${appId}`, {
51
+ method: "PATCH",
52
+ body: JSON.stringify({
53
+ data: {
54
+ type: "apps",
55
+ id: appId,
56
+ attributes: { contentRightsDeclaration: config.contentRightsDeclaration }
57
+ }
58
+ })
59
+ });
60
+ const infos = await client.request(`/apps/${appId}/appInfos?limit=10`), info = infos.data.find((item) => item.attributes.appStoreState === "PREPARE_FOR_SUBMISSION") ?? infos.data[0];
61
+ if (!info)
62
+ throw Error("[browser-extension] App Store Connect has no editable app information resource");
63
+ await client.request(`/appInfos/${info.id}`, {
64
+ method: "PATCH",
65
+ body: JSON.stringify({
66
+ data: {
67
+ type: "appInfos",
68
+ id: info.id,
69
+ relationships: {
70
+ primaryCategory: { data: { type: "appCategories", id: config.primaryCategory } }
71
+ }
72
+ }
73
+ })
74
+ });
75
+ const localizations = await client.request(`/appInfos/${info.id}/appInfoLocalizations?limit=200`), locale = config.locale ?? "en-US", localization = localizations.data.find((item) => item.attributes.locale === locale);
76
+ if (!localization)
77
+ throw Error(`[browser-extension] App Store Connect app information is missing the ${locale} localization`);
78
+ await client.request(`/appInfoLocalizations/${localization.id}`, {
79
+ method: "PATCH",
80
+ body: JSON.stringify({
81
+ data: {
82
+ type: "appInfoLocalizations",
83
+ id: localization.id,
84
+ attributes: {
85
+ subtitle: config.subtitle,
86
+ privacyPolicyUrl: config.privacyPolicyUrl
87
+ }
88
+ }
89
+ })
90
+ });
91
+ await client.request(`/ageRatingDeclarations/${info.id}`, {
92
+ method: "PATCH",
93
+ body: JSON.stringify({
94
+ data: {
95
+ type: "ageRatingDeclarations",
96
+ id: info.id,
97
+ attributes: ageRatingAttributes(config.ageRating)
98
+ }
99
+ })
100
+ });
101
+ return info.id;
102
+ }
103
+ async function ensurePrice(client, appId, config) {
104
+ if ((await client.request(`/appPriceSchedules/${appId}/manualPrices?limit=200`)).data.length)
105
+ return;
106
+ const baseTerritory = config.baseTerritory ?? "USA", pricePoints = await client.request(`/apps/${appId}/appPricePoints?filter[territory]=${encodeURIComponent(baseTerritory)}&limit=200`), requestedPrice = Number(config.price), pricePoint = pricePoints.data.find((item) => Number(item.attributes.customerPrice) === requestedPrice);
107
+ if (!pricePoint)
108
+ throw Error(`[browser-extension] App Store price ${config.price} is unavailable in ${baseTerritory}`);
109
+ const localId = "${price}";
110
+ await client.request("/appPriceSchedules", {
111
+ method: "POST",
112
+ body: JSON.stringify({
113
+ data: {
114
+ type: "appPriceSchedules",
115
+ relationships: {
116
+ app: { data: { type: "apps", id: appId } },
117
+ baseTerritory: { data: { type: "territories", id: baseTerritory } },
118
+ manualPrices: { data: [{ type: "appPrices", id: localId }] }
119
+ }
120
+ },
121
+ included: [{
122
+ type: "appPrices",
123
+ id: localId,
124
+ attributes: { startDate: null, endDate: null },
125
+ relationships: { appPricePoint: { data: { type: "appPricePoints", id: pricePoint.id } } }
126
+ }]
127
+ })
128
+ });
129
+ }
130
+ async function ensureAvailability(client, appId, availableInNewTerritories = !0) {
131
+ try {
132
+ if ((await client.request(`/apps/${appId}/appAvailabilityV2`)).data.attributes.availableInNewTerritories === availableInNewTerritories)
133
+ return;
134
+ return;
135
+ } catch (error) {
136
+ if (!(error instanceof Error) || !error.message.includes("(404)"))
137
+ throw error;
138
+ }
139
+ const included = (await client.request("/territories?limit=200")).data.map((territory, index) => {
140
+ return {
141
+ type: "territoryAvailabilities",
142
+ id: `\${territory-${index}}`,
143
+ attributes: { available: !0 },
144
+ relationships: { territory: { data: { type: "territories", id: territory.id } } }
145
+ };
146
+ });
147
+ await client.request("/v2/appAvailabilities", {
148
+ method: "POST",
149
+ body: JSON.stringify({
150
+ data: {
151
+ type: "appAvailabilities",
152
+ attributes: { availableInNewTerritories },
153
+ relationships: {
154
+ app: { data: { type: "apps", id: appId } },
155
+ territoryAvailabilities: {
156
+ data: included.map((item) => ({ type: item.type, id: item.id }))
157
+ }
158
+ }
159
+ },
160
+ included
161
+ })
162
+ });
163
+ }
164
+ async function ensureVersionLocalization(client, versionId, config) {
165
+ const locale = config.locale ?? "en-US";
166
+ let localization = (await client.request(`/appStoreVersions/${versionId}/appStoreVersionLocalizations?limit=200`)).data.find((item) => item.attributes.locale === locale);
167
+ if (!localization)
168
+ localization = (await client.request("/appStoreVersionLocalizations", {
169
+ method: "POST",
170
+ body: JSON.stringify({
171
+ data: {
172
+ type: "appStoreVersionLocalizations",
173
+ attributes: { locale },
174
+ relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } }
175
+ }
176
+ })
177
+ })).data;
178
+ await client.request(`/appStoreVersionLocalizations/${localization.id}`, {
179
+ method: "PATCH",
180
+ body: JSON.stringify({
181
+ data: {
182
+ type: "appStoreVersionLocalizations",
183
+ id: localization.id,
184
+ attributes: {
185
+ description: config.description,
186
+ keywords: config.keywords,
187
+ supportUrl: config.supportUrl,
188
+ ...config.marketingUrl ? { marketingUrl: config.marketingUrl } : {},
189
+ ...config.promotionalText ? { promotionalText: config.promotionalText } : {},
190
+ ...config.whatsNew ? { whatsNew: config.whatsNew } : {}
191
+ }
192
+ }
193
+ })
194
+ });
195
+ return localization.id;
196
+ }
197
+ async function ensureReviewDetail(client, versionId, config) {
198
+ const response = await client.request(`/appStoreVersions/${versionId}/appStoreReviewDetail`), attributes = {
199
+ contactFirstName: config.reviewContact.firstName,
200
+ contactLastName: config.reviewContact.lastName,
201
+ contactPhone: config.reviewContact.phone,
202
+ contactEmail: config.reviewContact.email,
203
+ demoAccountRequired: !1,
204
+ ...config.reviewContact.notes ? { notes: config.reviewContact.notes } : {}
205
+ };
206
+ if (response.data) {
207
+ await client.request(`/appStoreReviewDetails/${response.data.id}`, {
208
+ method: "PATCH",
209
+ body: JSON.stringify({ data: { type: "appStoreReviewDetails", id: response.data.id, attributes } })
210
+ });
211
+ return;
212
+ }
213
+ await client.request("/appStoreReviewDetails", {
214
+ method: "POST",
215
+ body: JSON.stringify({
216
+ data: {
217
+ type: "appStoreReviewDetails",
218
+ attributes,
219
+ relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } }
220
+ }
221
+ })
222
+ });
223
+ }
224
+ async function ensureScreenshotSet(client, localizationId, displayType) {
225
+ const existing = (await client.request(`/appStoreVersionLocalizations/${localizationId}/appScreenshotSets?limit=200`)).data.find((item) => item.attributes.screenshotDisplayType === displayType);
226
+ if (existing)
227
+ return existing.id;
228
+ return (await client.request("/appScreenshotSets", {
229
+ method: "POST",
230
+ body: JSON.stringify({
231
+ data: {
232
+ type: "appScreenshotSets",
233
+ attributes: { screenshotDisplayType: displayType },
234
+ relationships: {
235
+ appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: localizationId } }
236
+ }
237
+ }
238
+ })
239
+ })).data.id;
240
+ }
241
+ async function waitForScreenshot(client, id, timeoutMs, pollIntervalMs) {
242
+ const deadline = Date.now() + timeoutMs;
243
+ while (!0) {
244
+ const response = await client.request(`/appScreenshots/${id}`), state = response.data.attributes.assetDeliveryState?.state;
245
+ if (state === "COMPLETE")
246
+ return;
247
+ if (state === "FAILED") {
248
+ const detail = response.data.attributes.assetDeliveryState?.errors?.map((error) => error.message).filter(Boolean).join("; ");
249
+ throw Error(`[browser-extension] App Store screenshot processing failed${detail ? `: ${detail}` : ""}`);
250
+ }
251
+ if (Date.now() >= deadline)
252
+ throw Error(`[browser-extension] timed out waiting for App Store screenshot ${response.data.attributes.fileName}`);
253
+ await Bun.sleep(pollIntervalMs);
254
+ }
255
+ }
256
+ async function uploadScreenshot(client, setId, path, timeoutMs, pollIntervalMs) {
257
+ const bytes = await readFile(path), file = basename(path), checksum = createHash("md5").update(bytes).digest("hex"), reserved = await client.request("/appScreenshots", {
258
+ method: "POST",
259
+ body: JSON.stringify({
260
+ data: {
261
+ type: "appScreenshots",
262
+ attributes: { fileName: file, fileSize: bytes.byteLength },
263
+ relationships: { appScreenshotSet: { data: { type: "appScreenshotSets", id: setId } } }
264
+ }
265
+ })
266
+ });
267
+ for (const operation of reserved.data.attributes.uploadOperations ?? []) {
268
+ const body = bytes.subarray(operation.offset, operation.offset + operation.length);
269
+ await client.upload(operation.url, {
270
+ method: operation.method,
271
+ headers: Object.fromEntries(operation.requestHeaders.map((header) => [header.name, header.value])),
272
+ body
273
+ });
274
+ }
275
+ await client.request(`/appScreenshots/${reserved.data.id}`, {
276
+ method: "PATCH",
277
+ body: JSON.stringify({
278
+ data: {
279
+ type: "appScreenshots",
280
+ id: reserved.data.id,
281
+ attributes: { uploaded: !0, sourceFileChecksum: checksum }
282
+ }
283
+ })
284
+ });
285
+ await waitForScreenshot(client, reserved.data.id, timeoutMs, pollIntervalMs);
286
+ return reserved.data.id;
287
+ }
288
+ async function syncScreenshotSet(client, setId, paths, cwd, timeoutMs, pollIntervalMs) {
289
+ if (paths.length > 10)
290
+ throw Error("[browser-extension] App Store screenshot sets accept at most 10 images");
291
+ const files = await Promise.all(paths.map(async (path) => {
292
+ const absolute = resolve(cwd, path);
293
+ await stat(absolute);
294
+ const bytes = await readFile(absolute);
295
+ return { absolute, name: basename(absolute), checksum: createHash("md5").update(bytes).digest("hex") };
296
+ })), existing = await client.request(`/appScreenshotSets/${setId}/appScreenshots?limit=200`);
297
+ for (const screenshot of existing.data) {
298
+ const desired = files.find((file) => file.name === screenshot.attributes.fileName);
299
+ if (!desired || desired.checksum !== screenshot.attributes.sourceFileChecksum)
300
+ await client.request(`/appScreenshots/${screenshot.id}`, { method: "DELETE" });
301
+ }
302
+ for (const file of files)
303
+ if (!existing.data.find((screenshot) => screenshot.attributes.fileName === file.name && screenshot.attributes.sourceFileChecksum === file.checksum))
304
+ await uploadScreenshot(client, setId, file.absolute, timeoutMs, pollIntervalMs);
305
+ }
306
+ async function updateVersion(client, version, config) {
307
+ await client.request(`/appStoreVersions/${version.id}`, {
308
+ method: "PATCH",
309
+ body: JSON.stringify({
310
+ data: {
311
+ type: "appStoreVersions",
312
+ id: version.id,
313
+ attributes: {
314
+ copyright: config.copyright,
315
+ releaseType: config.releaseType ?? "AFTER_APPROVAL",
316
+ usesIdfa: config.usesIdfa ?? !1
317
+ }
318
+ }
319
+ })
320
+ });
321
+ if (version.buildId) {
322
+ const encryption = config.usesNonExemptEncryption ?? !1, build = await client.request(`/builds/${version.buildId}?fields[builds]=usesNonExemptEncryption`);
323
+ if (build.data.attributes.usesNonExemptEncryption === void 0 || build.data.attributes.usesNonExemptEncryption === null)
324
+ await client.request(`/builds/${version.buildId}`, {
325
+ method: "PATCH",
326
+ body: JSON.stringify({
327
+ data: {
328
+ type: "builds",
329
+ id: version.buildId,
330
+ attributes: { usesNonExemptEncryption: encryption }
331
+ }
332
+ })
333
+ });
334
+ }
335
+ }
336
+ async function submitVersions(client, appId, versions) {
337
+ const existing = await client.request(`/apps/${appId}/reviewSubmissions?limit=200`), ids = [];
338
+ for (const version of versions) {
339
+ const platform = applePlatform(version.platform), platformSubmissions = existing.data.filter((item) => item.attributes.platform === platform);
340
+ let submission;
341
+ for (const candidate of platformSubmissions) {
342
+ if (!((await client.request(`/reviewSubmissions/${candidate.id}/items?include=appStoreVersion&limit=200`)).included ?? []).some((item) => item.type === "appStoreVersions" && item.id === version.id))
343
+ continue;
344
+ submission = candidate;
345
+ if (candidate.attributes.state !== "READY_FOR_REVIEW") {
346
+ ids.push(candidate.id);
347
+ break;
348
+ }
349
+ }
350
+ if (submission && submission.attributes.state !== "READY_FOR_REVIEW")
351
+ continue;
352
+ submission ??= platformSubmissions.find((item) => item.attributes.state === "READY_FOR_REVIEW");
353
+ if (!submission)
354
+ submission = (await client.request("/reviewSubmissions", {
355
+ method: "POST",
356
+ body: JSON.stringify({
357
+ data: {
358
+ type: "reviewSubmissions",
359
+ attributes: { platform },
360
+ relationships: { app: { data: { type: "apps", id: appId } } }
361
+ }
362
+ })
363
+ })).data;
364
+ if (!((await client.request(`/reviewSubmissions/${submission.id}/items?include=appStoreVersion&limit=200`)).included ?? []).some((item) => item.type === "appStoreVersions" && item.id === version.id))
365
+ await client.request("/reviewSubmissionItems", {
366
+ method: "POST",
367
+ body: JSON.stringify({
368
+ data: {
369
+ type: "reviewSubmissionItems",
370
+ relationships: {
371
+ reviewSubmission: { data: { type: "reviewSubmissions", id: submission.id } },
372
+ appStoreVersion: { data: { type: "appStoreVersions", id: version.id } }
373
+ }
374
+ }
375
+ })
376
+ });
377
+ await client.request(`/reviewSubmissions/${submission.id}`, {
378
+ method: "PATCH",
379
+ body: JSON.stringify({
380
+ data: { type: "reviewSubmissions", id: submission.id, attributes: { submitted: !0 } }
381
+ })
382
+ });
383
+ ids.push(submission.id);
384
+ }
385
+ return ids;
386
+ }
387
+ export async function prepareSafariAppStoreSubmission(client, config, appId, versions, options = {}) {
388
+ const store = config.safariAppStore;
389
+ if (!store)
390
+ throw Error("[browser-extension] Safari App Store automation needs safariAppStore in config/extension.ts");
391
+ const cwd = options.cwd ?? process.cwd(), timeoutMs = options.screenshotTimeoutMs ?? 600000, pollIntervalMs = options.screenshotPollIntervalMs ?? 5000;
392
+ await updateAppInfo(client, appId, store);
393
+ await ensurePrice(client, appId, store);
394
+ await ensureAvailability(client, appId, store.availableInNewTerritories ?? !0);
395
+ const results = [];
396
+ for (const version of versions) {
397
+ await updateVersion(client, version, store);
398
+ const localizationId = await ensureVersionLocalization(client, version.id, store);
399
+ await ensureReviewDetail(client, version.id, store);
400
+ for (const displayType of displayTypesForPlatform(version.platform)) {
401
+ const paths = store.screenshots[displayType] ?? [];
402
+ if (!paths.length)
403
+ continue;
404
+ const setId = await ensureScreenshotSet(client, localizationId, displayType);
405
+ await syncScreenshotSet(client, setId, paths, cwd, timeoutMs, pollIntervalMs);
406
+ }
407
+ results.push({ platform: version.platform, versionId: version.id, localizationId });
408
+ }
409
+ const shouldSubmit = options.submit ?? store.submitForReview ?? !1;
410
+ return {
411
+ appId,
412
+ versions: results,
413
+ reviewSubmissionIds: shouldSubmit ? await submitVersions(client, appId, versions) : []
414
+ };
415
+ }
416
+ export async function submitSafariAppStore(config, options) {
417
+ if (!config.safariBundleId)
418
+ throw Error("[browser-extension] Safari App Store submission needs safariBundleId in config/extension.ts");
419
+ const client = new AppStoreConnectClient(options), app = await client.findApp(config.safariBundleId);
420
+ if (!app)
421
+ throw Error("[browser-extension] the App Store Connect app record is missing");
422
+ const selectedPlatforms = options.platforms ?? config.safariPlatforms ?? ["macos"], availableVersions = await client.listAppStoreVersions(app.id), versions = [], versionStates = [];
423
+ for (const platform of selectedPlatforms) {
424
+ const match = availableVersions.find((item) => item.attributes.platform === applePlatform(platform) && item.attributes.versionString === options.version);
425
+ if (!match)
426
+ throw Error(`[browser-extension] Safari ${platform} App Store version ${options.version} does not exist`);
427
+ const build = await client.request(`/appStoreVersions/${match.id}/build`);
428
+ versions.push({ platform, id: match.id, buildId: build.data?.id });
429
+ versionStates.push(match.attributes.appStoreState);
430
+ }
431
+ if ((options.submit ?? config.safariAppStore?.submitForReview ?? !1) && versionStates.every((state) => !editableVersionStates.has(state))) {
432
+ const preparedVersions = await Promise.all(versions.map(async (version) => {
433
+ const localizations = await client.request(`/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=200`);
434
+ return { platform: version.platform, versionId: version.id, localizationId: localizations.data[0]?.id ?? "" };
435
+ }));
436
+ return {
437
+ appId: app.id,
438
+ versions: preparedVersions,
439
+ reviewSubmissionIds: await submitVersions(client, app.id, versions)
440
+ };
441
+ }
442
+ return await prepareSafariAppStoreSubmission(client, config, app.id, versions, options);
443
+ }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export * from './build';
11
11
  export * from './app-store-connect';
12
+ export * from './app-store-submission';
12
13
  export * from './chrome-web-store';
13
14
  export * from './config';
14
15
  export * from './firefox-addons';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./build";
2
2
  export * from "./app-store-connect";
3
+ export * from "./app-store-submission";
3
4
  export * from "./chrome-web-store";
4
5
  export * from "./config";
5
6
  export * from "./firefox-addons";
package/dist/safari.d.ts CHANGED
@@ -107,6 +107,8 @@ export declare interface SafariPublishOptions extends SafariSyncOptions, AppStor
107
107
  validateOnly?: boolean
108
108
  build?: boolean
109
109
  platforms?: SafariPlatform[]
110
+ processingTimeoutMs?: number
111
+ processingPollIntervalMs?: number
110
112
  }
111
113
  export declare interface SafariPublishedArtifact {
112
114
  platform: SafariPlatform
@@ -118,4 +120,9 @@ export declare interface SafariPublishResult {
118
120
  exportPath: string
119
121
  buildNumber: string
120
122
  artifacts: SafariPublishedArtifact[]
123
+ attachments: Array<{ platform: SafariPlatform, versionId: string, buildId: string, buildNumber: string }>
124
+ appStoreSubmission?: {
125
+ versions: Array<{ platform: SafariPlatform, versionId: string, localizationId: string }>
126
+ reviewSubmissionIds: string[]
127
+ }
121
128
  }
package/dist/safari.js CHANGED
@@ -327,12 +327,12 @@ export async function publishSafariApp(config, options) {
327
327
  const platforms = resolveSafariPlatforms(config, options.platforms);
328
328
  if (!platforms.length)
329
329
  throw Error("[browser-extension] Safari publishing needs at least one platform");
330
- const { provisionSafariApp } = await import("./app-store-connect");
331
- if (!(await provisionSafariApp(config, {
330
+ const { AppStoreConnectClient, attachSafariBuilds, provisionSafariApp } = await import("./app-store-connect"), provisioned = await provisionSafariApp(config, {
332
331
  ...auth,
333
332
  version: options.version,
334
333
  platforms
335
- })).appRecord.exists)
334
+ });
335
+ if (!provisioned.appRecord.exists)
336
336
  throw Error("[browser-extension] the App Store Connect app record is missing; create it once in App Store Connect before publishing");
337
337
  if (options.build !== !1)
338
338
  await buildExtension(config, { target: "safari", version: options.version, cwd });
@@ -372,6 +372,7 @@ export async function publishSafariApp(config, options) {
372
372
  `MARKETING_VERSION=${options.version}`,
373
373
  `CURRENT_PROJECT_VERSION=${buildNumber}`,
374
374
  `DEVELOPMENT_TEAM=${teamId}`,
375
+ "INFOPLIST_KEY_ITSAppUsesNonExemptEncryption=NO",
375
376
  ...platform === "ios" ? ["CODE_SIGNING_ALLOWED=NO"] : [],
376
377
  ...platform === "macos" && config.safariAppCategory ? [`INFOPLIST_KEY_LSApplicationCategoryType=${config.safariAppCategory}`] : [],
377
378
  ...authArgs,
@@ -390,10 +391,20 @@ export async function publishSafariApp(config, options) {
390
391
  ], projectCwd, `Safari ${platform} export`);
391
392
  artifacts.push({ platform, archivePath, exportPath });
392
393
  }
394
+ const attachments = options.validateOnly ? [] : await attachSafariBuilds(new AppStoreConnectClient(auth), provisioned.appRecord.id, provisioned.appStoreVersions, buildNumber, {
395
+ timeoutMs: options.processingTimeoutMs,
396
+ pollIntervalMs: options.processingPollIntervalMs
397
+ }), appStoreSubmission = !options.validateOnly && config.safariAppStore ? await (await import("./app-store-submission")).prepareSafariAppStoreSubmission(new AppStoreConnectClient(auth), config, provisioned.appRecord.id, attachments.map((attachment) => ({
398
+ platform: attachment.platform,
399
+ id: attachment.versionId,
400
+ buildId: attachment.buildId
401
+ })), { cwd }) : void 0, firstArtifact = artifacts[0];
393
402
  return {
394
- archivePath: artifacts[0].archivePath,
395
- exportPath: artifacts[0].exportPath,
403
+ archivePath: firstArtifact.archivePath,
404
+ exportPath: firstArtifact.exportPath,
396
405
  buildNumber,
397
- artifacts
406
+ artifacts,
407
+ attachments,
408
+ appStoreSubmission: appStoreSubmission ? { versions: appStoreSubmission.versions, reviewSubmissionIds: appStoreSubmission.reviewSubmissionIds } : void 0
398
409
  };
399
410
  }
package/dist/types.d.ts CHANGED
@@ -68,6 +68,65 @@ export declare interface FirefoxAddonsConfig {
68
68
  requiresPayment?: boolean
69
69
  artifactsDir?: string
70
70
  }
71
+ export declare interface SafariAppStoreReviewContact {
72
+ firstName: string
73
+ lastName: string
74
+ phone: string
75
+ email: string
76
+ notes?: string
77
+ }
78
+ /** App Store age-rating answers. Omitted answers default to no content. */
79
+ export declare interface SafariAppStoreAgeRating {
80
+ advertising?: boolean
81
+ alcoholTobaccoOrDrugUseOrReferences?: SafariAgeRatingFrequency
82
+ contests?: SafariAgeRatingFrequency
83
+ gambling?: boolean
84
+ gamblingSimulated?: SafariAgeRatingFrequency
85
+ gunsOrOtherWeapons?: SafariAgeRatingFrequency
86
+ healthOrWellnessTopics?: boolean
87
+ lootBox?: boolean
88
+ medicalOrTreatmentInformation?: SafariAgeRatingFrequency
89
+ messagingAndChat?: boolean
90
+ parentalControls?: boolean
91
+ profanityOrCrudeHumor?: SafariAgeRatingFrequency
92
+ ageAssurance?: boolean
93
+ sexualContentGraphicAndNudity?: SafariAgeRatingFrequency
94
+ sexualContentOrNudity?: SafariAgeRatingFrequency
95
+ socialMedia?: boolean
96
+ socialMediaAgeRestricted?: boolean
97
+ horrorOrFearThemes?: SafariAgeRatingFrequency
98
+ matureOrSuggestiveThemes?: SafariAgeRatingFrequency
99
+ unrestrictedWebAccess?: boolean
100
+ userGeneratedContent?: boolean
101
+ violenceCartoonOrFantasy?: SafariAgeRatingFrequency
102
+ violenceRealisticProlongedGraphicOrSadistic?: SafariAgeRatingFrequency
103
+ violenceRealistic?: SafariAgeRatingFrequency
104
+ }
105
+ /** Metadata and assets managed through the official App Store Connect API. */
106
+ export declare interface SafariAppStoreConfig {
107
+ locale?: string
108
+ subtitle: string
109
+ privacyPolicyUrl: string
110
+ description: string
111
+ keywords: string
112
+ supportUrl: string
113
+ marketingUrl?: string
114
+ promotionalText?: string
115
+ whatsNew?: string
116
+ copyright: string
117
+ primaryCategory: string
118
+ contentRightsDeclaration: 'DOES_NOT_USE_THIRD_PARTY_CONTENT' | 'USES_THIRD_PARTY_CONTENT'
119
+ price: string
120
+ baseTerritory?: string
121
+ releaseType?: SafariAppStoreReleaseType
122
+ availableInNewTerritories?: boolean
123
+ usesIdfa?: boolean
124
+ usesNonExemptEncryption?: boolean
125
+ ageRating?: SafariAppStoreAgeRating
126
+ reviewContact: SafariAppStoreReviewContact
127
+ screenshots: Partial<Record<SafariScreenshotDisplayType, string[]>>
128
+ submitForReview?: boolean
129
+ }
71
130
  export declare interface ExtensionConfig {
72
131
  name: string
73
132
  description: string
@@ -78,6 +137,7 @@ export declare interface ExtensionConfig {
78
137
  safariTeamId?: string
79
138
  safariPlatforms?: SafariPlatform[]
80
139
  safariAppCategory?: string
140
+ safariAppStore?: SafariAppStoreConfig
81
141
  safariExclude?: string[]
82
142
  targets?: ExtensionTarget[]
83
143
  background?: string
@@ -110,3 +170,6 @@ export declare interface BuildOptions {
110
170
  */
111
171
  export type ExtensionTarget = 'chrome' | 'firefox' | 'safari';
112
172
  export type SafariPlatform = 'macos' | 'ios';
173
+ export type SafariScreenshotDisplayType = 'APP_DESKTOP' | 'APP_IPHONE_67' | 'APP_IPAD_PRO_3GEN_129';
174
+ export type SafariAppStoreReleaseType = 'MANUAL' | 'AFTER_APPROVAL' | 'SCHEDULED';
175
+ export type SafariAgeRatingFrequency = 'NONE' | 'INFREQUENT_OR_MILD' | 'FREQUENT_OR_INTENSE';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/browser-extension",
3
3
  "type": "module",
4
- "version": "0.70.144",
4
+ "version": "0.70.146",
5
5
  "description": "Build MV3 browser extensions (Chrome, Firefox, Safari) the Stacks way — manifest, content/background scripts, DNR rules, packaging, Safari container app, all config-driven.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [