@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 +31 -1
- package/dist/app-store-connect.d.ts +2 -0
- package/dist/app-store-connect.js +14 -2
- package/dist/app-store-submission.d.ts +27 -0
- package/dist/app-store-submission.js +443 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/safari.d.ts +4 -0
- package/dist/safari.js +8 -2
- package/dist/types.d.ts +63 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
package/dist/index.js
CHANGED
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
|
-
}),
|
|
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.
|
|
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": [
|