@stacksjs/browser-extension 0.70.141 → 0.70.142

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
@@ -4,8 +4,9 @@ Build MV3 browser extensions (Chrome + Firefox + Safari) the Stacks way — the
4
4
  manifest, content/background scripts, stx pages, `declarativeNetRequest`
5
5
  rulesets, and store-ready packaging are all derived from a single
6
6
  `config/extension.ts`. No hand-written `manifest.json`, no per-project build
7
- script. Safari additionally gets the macOS container app: a checked-in Xcode
8
- scaffold, appex resource sync, and an xcodebuild pipeline.
7
+ script. Safari additionally gets macOS, iPhone, and iPad container apps from
8
+ one web bundle, with checked-in macOS development and universal App Store
9
+ packaging owned by the framework.
9
10
 
10
11
  ## Quick start
11
12
 
@@ -46,7 +47,7 @@ repository instead of duplicating store orchestration:
46
47
  ```yaml
47
48
  jobs:
48
49
  publish:
49
- uses: stacksjs/stacks/.github/workflows/browser-extension-release.yml@v0.70.122
50
+ uses: stacksjs/stacks/.github/workflows/browser-extension-release.yml@v0.70.142
50
51
  with:
51
52
  chrome-publisher-id: ${{ vars.CHROME_WEB_STORE_PUBLISHER_ID }}
52
53
  safari-enabled: ${{ vars.ENABLE_SAFARI_PUBLISH == 'true' }}
@@ -65,9 +66,9 @@ GitHub Release only after the enabled stores succeed.
65
66
 
66
67
  ## Safari
67
68
 
68
- Safari Web Extensions ship inside a macOS app, so the safari target has two
69
- halves: the web bundle (`extension:build --target safari`) and the container
70
- app. The build rewrites promise-style `chrome.*` to `browser.*` (Safari's
69
+ Safari Web Extensions ship inside Apple container apps, so the safari target
70
+ has two halves: the web bundle (`extension:build --target safari`) and the
71
+ macOS/iOS apps. The build rewrites promise-style `chrome.*` to `browser.*` (Safari's
71
72
  `chrome.*` is callback-flavoured) and pins
72
73
  `browser_specific_settings.safari.strict_min_version` (default 18.4, the first
73
74
  Safari with MAIN-world content scripts + `match_about_blank`).
@@ -75,12 +76,17 @@ Safari with MAIN-world content scripts + `match_about_blank`).
75
76
  ```sh
76
77
  buddy extension:safari:init # scaffold the Xcode container app into safari/
77
78
  buddy extension:safari:provision # register both Bundle IDs + check the app record
78
- buddy extension:safari:app # build + sync into the appex + xcodebuild
79
- buddy extension:safari:publish # signed archive + App Store Connect upload
79
+ buddy extension:safari:app # build configured macOS/iOS apps
80
+ buddy extension:safari:app --platform ios # build for iPhone/iPad Simulator
81
+ buddy extension:safari:publish # archive + upload every configured Apple platform
80
82
  ```
81
83
 
82
84
  Set `safariBundleId` in the config (the appex gets `<safariBundleId>.Extension`)
83
- and `safariTeamId` to the Apple Developer team used for signing. Publishing
85
+ and `safariTeamId` to the Apple Developer team used for signing. Set
86
+ `safariPlatforms: ['macos', 'ios']` for a universal extension; iOS covers both
87
+ 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
84
90
  reads `APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_API_ISSUER_ID`, and
85
91
  `APP_STORE_CONNECT_API_KEY_PATH` from the environment. Run with
86
92
  `--validate-only` to exercise Apple's validation without uploading a build.
@@ -90,6 +96,11 @@ what `xcrun safari-web-extension-converter` generates, so day-to-day work
90
96
  never needs the converter; `--signed` builds need an Apple Development
91
97
  identity selected in Xcode.
92
98
 
99
+ For device testing, install the generated iOS app from Xcode, then enable the
100
+ extension in Settings > Apps > Safari > Extensions. The iOS target supports
101
+ iPhone and iPad from iOS 15 onward; the extension's configured Safari minimum
102
+ version can require a newer OS.
103
+
93
104
  ## Configure
94
105
 
95
106
  ```ts
@@ -103,6 +114,9 @@ export default defineExtension({
103
114
  chromeWebStore: { publisherId: 'publisher-id', itemId: 'extension-id' },
104
115
  firefoxAddons: { license: 'MIT', categories: ['privacy-security'] },
105
116
  safariBundleId: 'com.example.MyExtension', // Safari container app bundle id
117
+ safariTeamId: 'TEAM123456',
118
+ safariPlatforms: ['macos', 'ios'], // iOS includes iPhone and iPad
119
+ safariAppCategory: 'public.app-category.utilities',
106
120
  targets: ['chrome', 'firefox'],
107
121
 
108
122
  background: 'src/background/index.ts',
@@ -164,5 +178,7 @@ import {
164
178
  scaffoldSafariApp,
165
179
  syncSafariResources,
166
180
  buildSafariApp,
181
+ buildSafariUniversalApp,
182
+ createSafariUniversalProject,
167
183
  } from '@stacksjs/browser-extension'
168
184
  ```
@@ -1,5 +1,5 @@
1
1
  import type { AppStoreConnectAuth } from './safari';
2
- import type { ExtensionConfig } from './types';
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
5
  /**
@@ -25,21 +25,37 @@ export declare interface AppAttributes extends Record<string, unknown> {
25
25
  primaryLocale: string
26
26
  sku: string
27
27
  }
28
+ export declare interface AppStoreVersionAttributes extends Record<string, unknown> {
29
+ platform: AppStoreVersionPlatform
30
+ versionString: string
31
+ appStoreState: string
32
+ }
28
33
  export declare interface AppStoreConnectClientOptions extends AppStoreConnectAuth {
29
34
  baseUrl?: string
30
35
  fetch?: typeof globalThis.fetch
31
36
  now?: () => number
32
37
  }
38
+ export declare interface SafariAppStoreVersionResult {
39
+ platform: SafariPlatform
40
+ version: string
41
+ created: boolean
42
+ updated: boolean
43
+ id: string
44
+ }
33
45
  export declare interface SafariProvisionOptions extends AppStoreConnectClientOptions {
34
46
  checkOnly?: boolean
35
47
  platform?: BundleIdPlatform
48
+ version?: string
49
+ platforms?: SafariPlatform[]
36
50
  }
37
51
  export declare interface SafariProvisionResult {
38
52
  container: { identifier: string, exists: boolean, created: boolean }
39
53
  extension: { identifier: string, exists: boolean, created: boolean }
40
54
  appRecord: { exists: boolean, id?: string }
55
+ appStoreVersions: SafariAppStoreVersionResult[]
41
56
  }
42
57
  export type BundleIdPlatform = 'IOS' | 'MAC_OS' | 'UNIVERSAL';
58
+ export type AppStoreVersionPlatform = 'IOS' | 'MAC_OS';
43
59
  /** Minimal official App Store Connect client for Safari provisioning checks. */
44
60
  export declare class AppStoreConnectClient {
45
61
  constructor(options?: AppStoreConnectClientOptions);
@@ -47,4 +63,7 @@ export declare class AppStoreConnectClient {
47
63
  registerBundleId(identifier: string, name: string, platform?: BundleIdPlatform): Promise<AppStoreConnectResource<BundleIdAttributes>>;
48
64
  ensureBundleId(identifier: string, name: string, options?: { checkOnly?: boolean, platform?: BundleIdPlatform }): Promise<{ bundleId?: AppStoreConnectResource<BundleIdAttributes>, created: boolean }>;
49
65
  findApp(bundleId: string): Promise<AppStoreConnectResource<AppAttributes> | undefined>;
66
+ listAppStoreVersions(appId: string): Promise<Array<AppStoreConnectResource<AppStoreVersionAttributes>>>;
67
+ createAppStoreVersion(appId: string, platform: AppStoreVersionPlatform, versionString: string): Promise<AppStoreConnectResource<AppStoreVersionAttributes>>;
68
+ updateAppStoreVersion(versionId: string, versionString: string): Promise<AppStoreConnectResource<AppStoreVersionAttributes>>;
50
69
  }
@@ -79,14 +79,72 @@ export class AppStoreConnectClient {
79
79
  const query = new URLSearchParams({ "filter[bundleId]": bundleId });
80
80
  return (await this.request(`/apps?${query}`)).data.find((app) => app.attributes.bundleId === bundleId);
81
81
  }
82
+ async listAppStoreVersions(appId) {
83
+ return (await this.request(`/apps/${appId}/appStoreVersions?limit=200`)).data;
84
+ }
85
+ async createAppStoreVersion(appId, platform, versionString) {
86
+ return (await this.request("/appStoreVersions", {
87
+ method: "POST",
88
+ body: JSON.stringify({
89
+ data: {
90
+ type: "appStoreVersions",
91
+ attributes: { platform, versionString },
92
+ relationships: { app: { data: { type: "apps", id: appId } } }
93
+ }
94
+ })
95
+ })).data;
96
+ }
97
+ async updateAppStoreVersion(versionId, versionString) {
98
+ return (await this.request(`/appStoreVersions/${versionId}`, {
99
+ method: "PATCH",
100
+ body: JSON.stringify({
101
+ data: {
102
+ type: "appStoreVersions",
103
+ id: versionId,
104
+ attributes: { versionString }
105
+ }
106
+ })
107
+ })).data;
108
+ }
109
+ }
110
+ const editableVersionStates = new Set([
111
+ "PREPARE_FOR_SUBMISSION",
112
+ "DEVELOPER_REJECTED",
113
+ "REJECTED",
114
+ "METADATA_REJECTED"
115
+ ]);
116
+ function appStorePlatform(platform) {
117
+ return platform === "ios" ? "IOS" : "MAC_OS";
118
+ }
119
+ async function ensureConfiguredAppStoreVersions(client, appId, platforms, version) {
120
+ const versions = await client.listAppStoreVersions(appId), results = [];
121
+ for (const platform of [...new Set(platforms)]) {
122
+ const applePlatform = appStorePlatform(platform), exact = versions.find((item) => item.attributes.platform === applePlatform && item.attributes.versionString === version);
123
+ if (exact) {
124
+ results.push({ platform, version, created: !1, updated: !1, id: exact.id });
125
+ continue;
126
+ }
127
+ const editable = versions.find((item) => item.attributes.platform === applePlatform && editableVersionStates.has(item.attributes.appStoreState));
128
+ if (editable) {
129
+ const updated = await client.updateAppStoreVersion(editable.id, version);
130
+ editable.attributes.versionString = version;
131
+ results.push({ platform, version, created: !1, updated: !0, id: updated.id });
132
+ continue;
133
+ }
134
+ const created = await client.createAppStoreVersion(appId, applePlatform, version);
135
+ versions.push(created);
136
+ results.push({ platform, version, created: !0, updated: !1, id: created.id });
137
+ }
138
+ return results;
82
139
  }
83
140
  export async function provisionSafariApp(config, options = {}) {
84
141
  if (!config.safariBundleId)
85
142
  throw Error("[browser-extension] Safari provisioning needs safariBundleId in config/extension.ts");
86
- const client = new AppStoreConnectClient(options), identifier = config.safariBundleId, extensionIdentifier = `${identifier}.Extension`, container = await client.ensureBundleId(identifier, config.name, options), extension = await client.ensureBundleId(extensionIdentifier, `${config.name} Safari Extension`, options), app = await client.findApp(identifier);
143
+ const client = new AppStoreConnectClient(options), identifier = config.safariBundleId, extensionIdentifier = `${identifier}.Extension`, platforms = options.platforms ?? config.safariPlatforms ?? ["macos"], bundleIdPlatform = options.platform ?? (platforms.includes("ios") ? "UNIVERSAL" : "MAC_OS"), bundleOptions = { checkOnly: options.checkOnly, platform: bundleIdPlatform }, container = await client.ensureBundleId(identifier, config.name, bundleOptions), extension = await client.ensureBundleId(extensionIdentifier, `${config.name} Safari Extension`, bundleOptions), app = await client.findApp(identifier), appStoreVersions = app && options.version && !options.checkOnly ? await ensureConfiguredAppStoreVersions(client, app.id, platforms, options.version) : [];
87
144
  return {
88
145
  container: { identifier, exists: Boolean(container.bundleId), created: container.created },
89
146
  extension: { identifier: extensionIdentifier, exists: Boolean(extension.bundleId), created: extension.created },
90
- appRecord: { exists: Boolean(app), id: app?.id }
147
+ appRecord: { exists: Boolean(app), id: app?.id },
148
+ appStoreVersions
91
149
  };
92
150
  }
package/dist/safari.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionConfig } from './types';
1
+ import type { ExtensionConfig, SafariPlatform } from './types';
2
2
  /**
3
3
  * Safari container-app tooling: scaffold the Xcode project, sync the built
4
4
  * safari bundle into the appex Resources, and xcodebuild the app.
@@ -18,6 +18,9 @@ import type { ExtensionConfig } from './types';
18
18
  export declare function safariAppName(config: ExtensionConfig): string;
19
19
  /** Directory the container app is scaffolded into. */
20
20
  export declare function safariProjectDir(cwd?: unknown, dir?: string): string;
21
+ /** Safari platforms selected in config, de-duplicated in stable order. */
22
+ export declare function resolveSafariPlatforms(config: ExtensionConfig, platforms?: SafariPlatform[]): SafariPlatform[];
23
+ export declare function safariPackagerArgs(input: string, projectLocation: string, appName: string, bundleId: string, platforms: SafariPlatform[]): string[];
21
24
  /** Upgrade a Stacks-generated project that copied Resources as a nested folder. */
22
25
  export declare function migrateSafariResourceBuildPhase(project: string, appName: string): string;
23
26
  /**
@@ -34,6 +37,10 @@ export declare function scaffoldSafariApp(config: ExtensionConfig, options?: Saf
34
37
  * marketing-site pages built into dist) are kept out.
35
38
  */
36
39
  export declare function syncSafariResources(config: ExtensionConfig, options?: SafariSyncOptions): Promise<{ resources: string, files: number }>;
40
+ /** Generate a clean Apple-supported Safari project for macOS, iOS, or both. */
41
+ export declare function createSafariUniversalProject(config: ExtensionConfig, options?: SafariUniversalProjectOptions): Promise<SafariUniversalProject>;
42
+ /** Build Apple-supported macOS and iOS Safari containers from one web bundle. */
43
+ export declare function buildSafariUniversalApp(config: ExtensionConfig, options?: SafariUniversalBuildOptions): Promise<SafariUniversalBuildResult>;
37
44
  /**
38
45
  * Full pipeline: build the safari bundle, sync it into the appex Resources,
39
46
  * then xcodebuild the container app when full Xcode is available. Returns the
@@ -47,7 +54,7 @@ export declare function resolveAppStoreConnectAuth(options: AppStoreConnectAuth)
47
54
  * Store Connect. Xcode owns certificate/profile creation and the upload so the
48
55
  * same command works locally and in macOS CI with an API key.
49
56
  */
50
- export declare function publishSafariApp(config: ExtensionConfig, options: SafariPublishOptions): Promise<{ archivePath: string, exportPath: string, buildNumber: string }>;
57
+ export declare function publishSafariApp(config: ExtensionConfig, options: SafariPublishOptions): Promise<SafariPublishResult>;
51
58
  export declare interface SafariScaffoldOptions {
52
59
  bundleId?: string
53
60
  dir?: string
@@ -62,6 +69,25 @@ export declare interface SafariSyncOptions {
62
69
  dir?: string
63
70
  cwd?: string
64
71
  }
72
+ export declare interface SafariUniversalProjectOptions extends SafariSyncOptions {
73
+ platforms?: SafariPlatform[]
74
+ build?: boolean
75
+ version?: string
76
+ }
77
+ export declare interface SafariUniversalProject {
78
+ dir: string
79
+ project: string
80
+ resources: string
81
+ platforms: SafariPlatform[]
82
+ }
83
+ export declare interface SafariUniversalBuildOptions extends SafariUniversalProjectOptions {
84
+ release?: boolean
85
+ signed?: boolean
86
+ skipXcodebuild?: boolean
87
+ }
88
+ export declare interface SafariUniversalBuildResult extends SafariUniversalProject {
89
+ appPaths: Partial<Record<SafariPlatform, string>>
90
+ }
65
91
  export declare interface SafariAppBuildOptions extends SafariSyncOptions {
66
92
  release?: boolean
67
93
  signed?: boolean
@@ -80,4 +106,16 @@ export declare interface SafariPublishOptions extends SafariSyncOptions, AppStor
80
106
  teamId?: string
81
107
  validateOnly?: boolean
82
108
  build?: boolean
109
+ platforms?: SafariPlatform[]
110
+ }
111
+ export declare interface SafariPublishedArtifact {
112
+ platform: SafariPlatform
113
+ archivePath: string
114
+ exportPath: string
115
+ }
116
+ export declare interface SafariPublishResult {
117
+ archivePath: string
118
+ exportPath: string
119
+ buildNumber: string
120
+ artifacts: SafariPublishedArtifact[]
83
121
  }
package/dist/safari.js CHANGED
@@ -11,6 +11,34 @@ export function safariAppName(config) {
11
11
  export function safariProjectDir(cwd = process.cwd(), dir = "safari") {
12
12
  return resolve(cwd, dir);
13
13
  }
14
+ export function resolveSafariPlatforms(config, platforms) {
15
+ const selected = platforms ?? config.safariPlatforms ?? ["macos"];
16
+ return [...new Set(selected)];
17
+ }
18
+ function safariPlatformFlag(platforms) {
19
+ if (platforms.length !== 1)
20
+ return;
21
+ return platforms[0] === "ios" ? "--ios-only" : "--macos-only";
22
+ }
23
+ export function safariPackagerArgs(input, projectLocation, appName, bundleId, platforms) {
24
+ const platformFlag = safariPlatformFlag(platforms);
25
+ return [
26
+ "safari-web-extension-packager",
27
+ "--project-location",
28
+ projectLocation,
29
+ "--app-name",
30
+ appName,
31
+ "--bundle-identifier",
32
+ bundleId,
33
+ "--swift",
34
+ "--copy-resources",
35
+ "--no-open",
36
+ "--no-prompt",
37
+ "--force",
38
+ ...platformFlag ? [platformFlag] : [],
39
+ input
40
+ ];
41
+ }
14
42
  function templateRoot() {
15
43
  return resolve(import.meta.dir, "..", "safari-template");
16
44
  }
@@ -125,24 +153,12 @@ async function generateAppIcons(config, dir, vars, iconsDir, cwd) {
125
153
  return;
126
154
  }
127
155
  }
128
- export async function syncSafariResources(config, options = {}) {
129
- const cwd = options.cwd ?? process.cwd(), outdir = resolve(cwd, options.outdir ?? resolveOutdir(config, "safari"));
130
- if (!existsSync(join(outdir, "manifest.json")))
131
- throw Error(`[browser-extension] ${outdir}/manifest.json is missing. Run extension:build --target safari first.`);
132
- const appName = safariAppName(config), projectDir = safariProjectDir(cwd, options.dir), projectPath = join(projectDir, `${appName}.xcodeproj`, "project.pbxproj");
133
- if (existsSync(projectPath)) {
134
- const project = await Bun.file(projectPath).text(), migrated = migrateSafariResourceBuildPhase(project, appName);
135
- if (migrated !== project)
136
- await Bun.write(projectPath, migrated);
137
- }
138
- const resources = join(projectDir, `${appName} Extension`, "Resources"), exclude = new Set(config.safariExclude ?? []);
139
- if (existsSync(resources)) {
140
- for (const entry of readdirSync(resources))
141
- if (entry !== ".gitkeep")
142
- await rm(join(resources, entry), { recursive: !0, force: !0 });
143
- }
156
+ async function mirrorSafariResources(config, outdir, resources, keepPlaceholder = !1) {
157
+ const exclude = new Set(config.safariExclude ?? []);
158
+ await rm(resources, { recursive: !0, force: !0 });
144
159
  await mkdir(resources, { recursive: !0 });
145
- let files = 0;
160
+ if (keepPlaceholder)
161
+ await Bun.write(join(resources, ".gitkeep"), "");
146
162
  const synced = [];
147
163
  for (const rel of walk(outdir)) {
148
164
  if (exclude.has(rel) || rel.split("/").includes(".DS_Store"))
@@ -151,16 +167,84 @@ export async function syncSafariResources(config, options = {}) {
151
167
  await mkdir(dirname(dest), { recursive: !0 });
152
168
  cpSync(join(outdir, rel), dest);
153
169
  synced.push(rel);
154
- files += 1;
155
170
  }
156
- const inputs = synced.map((rel) => `$(SRCROOT)/${appName} Extension/Resources/${rel}`).join(`
171
+ return synced;
172
+ }
173
+ export async function syncSafariResources(config, options = {}) {
174
+ const cwd = options.cwd ?? process.cwd(), outdir = resolve(cwd, options.outdir ?? resolveOutdir(config, "safari"));
175
+ if (!existsSync(join(outdir, "manifest.json")))
176
+ throw Error(`[browser-extension] ${outdir}/manifest.json is missing. Run extension:build --target safari first.`);
177
+ const appName = safariAppName(config), projectDir = safariProjectDir(cwd, options.dir), projectPath = join(projectDir, `${appName}.xcodeproj`, "project.pbxproj");
178
+ if (existsSync(projectPath)) {
179
+ const project = await Bun.file(projectPath).text(), migrated = migrateSafariResourceBuildPhase(project, appName);
180
+ if (migrated !== project)
181
+ await Bun.write(projectPath, migrated);
182
+ }
183
+ const resources = join(projectDir, `${appName} Extension`, "Resources"), synced = await mirrorSafariResources(config, outdir, resources, !0), inputs = synced.map((rel) => `$(SRCROOT)/${appName} Extension/Resources/${rel}`).join(`
157
184
  `), outputs = synced.map((rel) => `$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/${rel}`).join(`
158
185
  `);
159
186
  await Bun.write(join(projectDir, `${appName} Extension`, "Resources.inputs.xcfilelist"), `${inputs}
160
187
  `);
161
188
  await Bun.write(join(projectDir, `${appName} Extension`, "Resources.outputs.xcfilelist"), `${outputs}
162
189
  `);
163
- return { resources, files };
190
+ return { resources, files: synced.length };
191
+ }
192
+ export async function createSafariUniversalProject(config, options = {}) {
193
+ const cwd = options.cwd ?? process.cwd();
194
+ if (options.build !== !1) {
195
+ if (!options.version)
196
+ throw Error("[browser-extension] createSafariUniversalProject needs a version to build the extension");
197
+ await buildExtension(config, { target: "safari", version: options.version, cwd });
198
+ }
199
+ const outdir = resolve(cwd, options.outdir ?? resolveOutdir(config, "safari"));
200
+ if (!existsSync(join(outdir, "manifest.json")))
201
+ throw Error(`[browser-extension] ${outdir}/manifest.json is missing. Run extension:build --target safari first.`);
202
+ const appName = safariAppName(config), bundleId = config.safariBundleId;
203
+ if (!bundleId)
204
+ throw Error("[browser-extension] universal Safari packaging needs safariBundleId in config/extension.ts");
205
+ const platforms = resolveSafariPlatforms(config, options.platforms);
206
+ if (!platforms.length)
207
+ throw Error("[browser-extension] universal Safari packaging needs at least one platform");
208
+ const buildDir = join(safariProjectDir(cwd, options.dir), "build"), resources = join(buildDir, "universal-resources"), projectLocation = join(buildDir, "universal-project");
209
+ await mirrorSafariResources(config, outdir, resources);
210
+ await rm(projectLocation, { recursive: !0, force: !0 });
211
+ await mkdir(projectLocation, { recursive: !0 });
212
+ const args = safariPackagerArgs(resources, projectLocation, appName, bundleId, platforms), exitCode = await Bun.spawn(["xcrun", ...args], { cwd, stdout: "inherit", stderr: "inherit" }).exited;
213
+ if (exitCode !== 0)
214
+ throw Error(`[browser-extension] Safari web extension packager failed (${exitCode})`);
215
+ const dir = join(projectLocation, appName), project = join(dir, `${appName}.xcodeproj`);
216
+ if (!existsSync(project))
217
+ throw Error(`[browser-extension] Safari packager did not create ${project}`);
218
+ return { dir, project, resources, platforms };
219
+ }
220
+ export async function buildSafariUniversalApp(config, options = {}) {
221
+ const generated = await createSafariUniversalProject(config, options), appPaths = {};
222
+ if (options.skipXcodebuild)
223
+ return { ...generated, appPaths };
224
+ const appName = safariAppName(config), configuration = options.release ? "Release" : "Debug", derivedData = join(safariProjectDir(options.cwd ?? process.cwd(), options.dir), "build", "universal-derived");
225
+ await rm(derivedData, { recursive: !0, force: !0 });
226
+ for (const platform of generated.platforms) {
227
+ const ios = platform === "ios", args = [
228
+ "xcodebuild",
229
+ "-project",
230
+ generated.project,
231
+ "-scheme",
232
+ `${appName} (${ios ? "iOS" : "macOS"})`,
233
+ "-configuration",
234
+ configuration,
235
+ "-destination",
236
+ ios ? "generic/platform=iOS Simulator" : "generic/platform=macOS",
237
+ "-derivedDataPath",
238
+ derivedData,
239
+ ...ios ? ["-sdk", "iphonesimulator", "CODE_SIGNING_ALLOWED=NO"] : options.signed ? ["-allowProvisioningUpdates"] : ["CODE_SIGNING_ALLOWED=NO"],
240
+ "build"
241
+ ], exitCode = await Bun.spawn(args, { cwd: generated.dir, stdout: "inherit", stderr: "inherit" }).exited;
242
+ if (exitCode !== 0)
243
+ throw Error(`[browser-extension] Safari ${platform} app build failed (${exitCode})`);
244
+ const products = ios ? `${configuration}-iphonesimulator` : configuration;
245
+ appPaths[platform] = join(derivedData, "Build", "Products", products, `${appName}.app`);
246
+ }
247
+ return { ...generated, appPaths };
164
248
  }
165
249
  export async function buildSafariApp(config, options = {}) {
166
250
  const cwd = options.cwd ?? process.cwd();
@@ -207,6 +291,11 @@ function xcodeAuthArgs(auth) {
207
291
  auth.issuerId
208
292
  ];
209
293
  }
294
+ async function runXcodebuild(args, cwd, operation) {
295
+ const exitCode = await Bun.spawn(["xcodebuild", ...args], { cwd, stdout: "inherit", stderr: "inherit" }).exited;
296
+ if (exitCode !== 0)
297
+ throw Error(`[browser-extension] ${operation} failed (${exitCode})`);
298
+ }
210
299
  function exportOptionsPlist(method, teamId) {
211
300
  return `<?xml version="1.0" encoding="UTF-8"?>
212
301
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -235,17 +324,76 @@ export async function publishSafariApp(config, options) {
235
324
  const auth = resolveAppStoreConnectAuth(options), buildNumber = options.buildNumber ?? process.env.GITHUB_RUN_NUMBER ?? String(Math.floor(Date.now() / 1000));
236
325
  if (!/^\d+(?:\.\d+){0,2}$/.test(buildNumber))
237
326
  throw Error(`[browser-extension] invalid Safari build number ${buildNumber}; use one to three dot-separated integers`);
327
+ const platforms = resolveSafariPlatforms(config, options.platforms);
328
+ if (!platforms.length)
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, {
332
+ ...auth,
333
+ version: options.version,
334
+ platforms
335
+ })).appRecord.exists)
336
+ throw Error("[browser-extension] the App Store Connect app record is missing; create it once in App Store Connect before publishing");
238
337
  if (options.build !== !1)
239
338
  await buildExtension(config, { target: "safari", version: options.version, cwd });
240
- await syncSafariResources(config, options);
241
- const appName = safariAppName(config), dir = safariProjectDir(cwd, options.dir), buildDir = join(dir, "build"), archivePath = join(buildDir, `${appName}.xcarchive`), exportPath = join(buildDir, options.validateOnly ? "validation" : "upload"), plistPath = join(buildDir, options.validateOnly ? "ExportOptions.validation.plist" : "ExportOptions.app-store.plist"), authArgs = xcodeAuthArgs(auth);
339
+ const appName = safariAppName(config), dir = safariProjectDir(cwd, options.dir), buildDir = join(dir, "build"), universal = platforms.includes("ios");
340
+ let project, projectCwd;
341
+ if (universal) {
342
+ const generated = await createSafariUniversalProject(config, {
343
+ ...options,
344
+ build: !1,
345
+ platforms
346
+ });
347
+ project = generated.project;
348
+ projectCwd = generated.dir;
349
+ } else {
350
+ await syncSafariResources(config, options);
351
+ project = join(dir, `${appName}.xcodeproj`);
352
+ projectCwd = dir;
353
+ }
354
+ const authArgs = xcodeAuthArgs(auth);
242
355
  await mkdir(buildDir, { recursive: !0 });
243
- await rm(archivePath, { recursive: !0, force: !0 });
244
- await rm(exportPath, { recursive: !0, force: !0 });
245
- const project = join(dir, `${appName}.xcodeproj`);
246
- await Bun.$`xcodebuild -project ${project} -scheme ${appName} -configuration Release -destination generic/platform=macOS -archivePath ${archivePath} MARKETING_VERSION=${options.version} CURRENT_PROJECT_VERSION=${buildNumber} DEVELOPMENT_TEAM=${teamId} ${authArgs} archive`;
247
- const method = options.validateOnly ? "validation" : "app-store-connect";
248
- await Bun.write(plistPath, exportOptionsPlist(method, teamId));
249
- await Bun.$`xcodebuild -exportArchive -archivePath ${archivePath} -exportPath ${exportPath} -exportOptionsPlist ${plistPath} ${authArgs}`;
250
- return { archivePath, exportPath, buildNumber };
356
+ const method = options.validateOnly ? "validation" : "app-store-connect", artifacts = [];
357
+ for (const platform of platforms) {
358
+ const suffix = universal ? `-${platform}` : "", archivePath = join(buildDir, `${appName}${suffix}.xcarchive`), exportPath = join(buildDir, `${options.validateOnly ? "validation" : "upload"}${suffix}`), plistPath = join(buildDir, `ExportOptions.${method}${suffix}.plist`), scheme = universal ? `${appName} (${platform === "ios" ? "iOS" : "macOS"})` : appName, destination = platform === "ios" ? "generic/platform=iOS" : "generic/platform=macOS";
359
+ await rm(archivePath, { recursive: !0, force: !0 });
360
+ await rm(exportPath, { recursive: !0, force: !0 });
361
+ await runXcodebuild([
362
+ "-project",
363
+ project,
364
+ "-scheme",
365
+ scheme,
366
+ "-configuration",
367
+ "Release",
368
+ "-destination",
369
+ destination,
370
+ "-archivePath",
371
+ archivePath,
372
+ `MARKETING_VERSION=${options.version}`,
373
+ `CURRENT_PROJECT_VERSION=${buildNumber}`,
374
+ `DEVELOPMENT_TEAM=${teamId}`,
375
+ ...platform === "ios" ? ["CODE_SIGNING_ALLOWED=NO"] : [],
376
+ ...platform === "macos" && config.safariAppCategory ? [`INFOPLIST_KEY_LSApplicationCategoryType=${config.safariAppCategory}`] : [],
377
+ ...authArgs,
378
+ "archive"
379
+ ], projectCwd, `Safari ${platform} archive`);
380
+ await Bun.write(plistPath, exportOptionsPlist(method, teamId));
381
+ await runXcodebuild([
382
+ "-exportArchive",
383
+ "-archivePath",
384
+ archivePath,
385
+ "-exportPath",
386
+ exportPath,
387
+ "-exportOptionsPlist",
388
+ plistPath,
389
+ ...authArgs
390
+ ], projectCwd, `Safari ${platform} export`);
391
+ artifacts.push({ platform, archivePath, exportPath });
392
+ }
393
+ return {
394
+ archivePath: artifacts[0].archivePath,
395
+ exportPath: artifacts[0].exportPath,
396
+ buildNumber,
397
+ artifacts
398
+ };
251
399
  }
package/dist/types.d.ts CHANGED
@@ -76,6 +76,8 @@ export declare interface ExtensionConfig {
76
76
  firefoxAddons?: FirefoxAddonsConfig
77
77
  safariBundleId?: string
78
78
  safariTeamId?: string
79
+ safariPlatforms?: SafariPlatform[]
80
+ safariAppCategory?: string
79
81
  safariExclude?: string[]
80
82
  targets?: ExtensionTarget[]
81
83
  background?: string
@@ -107,3 +109,4 @@ export declare interface BuildOptions {
107
109
  * or per-project build script.
108
110
  */
109
111
  export type ExtensionTarget = 'chrome' | 'firefox' | 'safari';
112
+ export type SafariPlatform = 'macos' | 'ios';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/browser-extension",
3
3
  "type": "module",
4
- "version": "0.70.141",
4
+ "version": "0.70.142",
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": [