@stacksjs/browser-extension 0.70.145 → 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,6 +80,7 @@ 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`)
@@ -91,12 +93,19 @@ selects each processed build on its matching App Store version. Publishing
91
93
  reads `APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_API_ISSUER_ID`, and
92
94
  `APP_STORE_CONNECT_API_KEY_PATH` from the environment. Run with
93
95
  `--validate-only` to exercise Apple's validation without uploading a build.
94
- 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,
95
97
  etc.) in `safariExclude` so it stays out of the appex. The scaffold mirrors
96
98
  what `xcrun safari-web-extension-converter` generates, so day-to-day work
97
99
  never needs the converter; `--signed` builds need an Apple Development
98
100
  identity selected in Xcode.
99
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
+
100
109
  For device testing, install the generated iOS app from Xcode, then enable the
101
110
  extension in Settings > Apps > Safari > Extensions. The iOS target supports
102
111
  iPhone and iPad from iOS 15 onward; the extension's configured Safari minimum
@@ -118,6 +127,27 @@ export default defineExtension({
118
127
  safariTeamId: 'TEAM123456',
119
128
  safariPlatforms: ['macos', 'ios'], // iOS includes iPhone and iPad
120
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
+ },
121
151
  targets: ['chrome', 'firefox'],
122
152
 
123
153
  background: 'src/background/index.ts',
@@ -89,6 +89,8 @@ export type AppStoreVersionPlatform = 'IOS' | 'MAC_OS';
89
89
  /** Minimal official App Store Connect client for Safari provisioning checks. */
90
90
  export declare class AppStoreConnectClient {
91
91
  constructor(options?: AppStoreConnectClientOptions);
92
+ request<T>(path: string, init?: RequestInit): Promise<T>;
93
+ upload(url: string, init: RequestInit): Promise<void>;
92
94
  findBundleId(identifier: string): Promise<AppStoreConnectResource<BundleIdAttributes> | undefined>;
93
95
  registerBundleId(identifier: string, name: string, platform?: BundleIdPlatform): Promise<AppStoreConnectResource<BundleIdAttributes>>;
94
96
  ensureBundleId(identifier: string, name: string, options?: { checkOnly?: boolean, platform?: BundleIdPlatform }): Promise<{ bundleId?: AppStoreConnectResource<BundleIdAttributes>, created: boolean }>;
@@ -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,13 +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
  }
50
57
  if (response.status === 204)
51
58
  return;
52
59
  return await response.json();
53
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
+ }
54
66
  async findBundleId(identifier) {
55
67
  const query = new URLSearchParams({ "filter[identifier]": identifier });
56
68
  return (await this.request(`/bundleIds?${query}`)).data.find((bundleId) => bundleId.attributes.identifier === identifier);
@@ -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
@@ -121,4 +121,8 @@ export declare interface SafariPublishResult {
121
121
  buildNumber: string
122
122
  artifacts: SafariPublishedArtifact[]
123
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
+ }
124
128
  }
package/dist/safari.js CHANGED
@@ -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,
@@ -393,12 +394,17 @@ export async function publishSafariApp(config, options) {
393
394
  const attachments = options.validateOnly ? [] : await attachSafariBuilds(new AppStoreConnectClient(auth), provisioned.appRecord.id, provisioned.appStoreVersions, buildNumber, {
394
395
  timeoutMs: options.processingTimeoutMs,
395
396
  pollIntervalMs: options.processingPollIntervalMs
396
- }), firstArtifact = artifacts[0];
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];
397
402
  return {
398
403
  archivePath: firstArtifact.archivePath,
399
404
  exportPath: firstArtifact.exportPath,
400
405
  buildNumber,
401
406
  artifacts,
402
- attachments
407
+ attachments,
408
+ appStoreSubmission: appStoreSubmission ? { versions: appStoreSubmission.versions, reviewSubmissionIds: appStoreSubmission.reviewSubmissionIds } : void 0
403
409
  };
404
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.145",
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": [