@stacksjs/browser-extension 0.70.113 → 0.70.114

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
@@ -1,9 +1,11 @@
1
1
  # @stacksjs/browser-extension
2
2
 
3
- Build MV3 browser extensions (Chrome + Firefox) the Stacks way — the manifest,
4
- content/background scripts, stx pages, `declarativeNetRequest` rulesets, and
5
- store-ready packaging are all derived from a single `config/extension.ts`. No
6
- hand-written `manifest.json`, no per-project build script.
3
+ Build MV3 browser extensions (Chrome + Firefox + Safari) the Stacks way — the
4
+ manifest, content/background scripts, stx pages, `declarativeNetRequest`
5
+ rulesets, and store-ready packaging are all derived from a single
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
9
 
8
10
  ## Quick start
9
11
 
@@ -11,9 +13,36 @@ hand-written `manifest.json`, no per-project build script.
11
13
  buddy extension:init # scaffold config/extension.ts + starter files
12
14
  buddy extension:build # build all targets → dist/ (+ dist-firefox/)
13
15
  buddy extension:build --target chrome
16
+ buddy extension:build --target safari # → dist-safari/ (browser.* namespace, safari manifest)
14
17
  buddy extension:package # build + zip store-ready archives
15
18
  ```
16
19
 
20
+ ## Safari
21
+
22
+ Safari Web Extensions ship inside a macOS app, so the safari target has two
23
+ halves: the web bundle (`extension:build --target safari`) and the container
24
+ app. The build rewrites promise-style `chrome.*` to `browser.*` (Safari's
25
+ `chrome.*` is callback-flavoured) and pins
26
+ `browser_specific_settings.safari.strict_min_version` (default 18.4, the first
27
+ Safari with MAIN-world content scripts + `match_about_blank`).
28
+
29
+ ```sh
30
+ buddy extension:safari:init # scaffold the Xcode container app into safari/
31
+ buddy extension:safari:app # build + sync into the appex + xcodebuild
32
+ buddy extension:safari:publish # signed archive + App Store Connect upload
33
+ ```
34
+
35
+ Set `safariBundleId` in the config (the appex gets `<safariBundleId>.Extension`)
36
+ and `safariTeamId` to the Apple Developer team used for signing. Publishing
37
+ reads `APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_API_ISSUER_ID`, and
38
+ `APP_STORE_CONNECT_API_KEY_PATH` from the environment. Run with
39
+ `--validate-only` to exercise Apple's validation without uploading a build.
40
+ and list any build output that is not part of the extension (marketing pages,
41
+ etc.) in `safariExclude` so it stays out of the appex. The scaffold mirrors
42
+ what `xcrun safari-web-extension-converter` generates, so day-to-day work
43
+ never needs the converter; `--signed` builds need an Apple Development
44
+ identity selected in Xcode.
45
+
17
46
  ## Configure
18
47
 
19
48
  ```ts
@@ -24,6 +53,7 @@ export default defineExtension({
24
53
  name: 'My Extension',
25
54
  description: 'Does something useful.',
26
55
  geckoId: 'my-ext@example.com', // required to ship on Firefox
56
+ safariBundleId: 'com.example.MyExtension', // Safari container app bundle id
27
57
  targets: ['chrome', 'firefox'],
28
58
 
29
59
  background: 'src/background/index.ts',
@@ -49,6 +79,7 @@ export default defineExtension({
49
79
  hostPermissions: ['http://*/*', 'https://*/*'],
50
80
  minimumChromeVersion: '111',
51
81
  firefoxMinVersion: '140.0',
82
+ safariMinVersion: '18.4',
52
83
  webAccessibleResources: [{ resources: ['stubs/*.js'], matches: ['<all_urls>'] }],
53
84
  },
54
85
 
@@ -73,5 +104,14 @@ vs Firefox event-page + `browser_specific_settings.gecko`), and runs your
73
104
  ## Programmatic API
74
105
 
75
106
  ```ts
76
- import { buildExtension, buildAllTargets, packageExtension, generateManifest } from '@stacksjs/browser-extension'
107
+ import {
108
+ buildExtension,
109
+ buildAllTargets,
110
+ packageExtension,
111
+ generateManifest,
112
+ rewriteBrowserNamespace,
113
+ scaffoldSafariApp,
114
+ syncSafariResources,
115
+ buildSafariApp,
116
+ } from '@stacksjs/browser-extension'
77
117
  ```
package/dist/build.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { BuildOptions, ExtensionConfig, ExtensionTarget } from './types';
2
2
  /** Resolve the output directory for a target. */
3
3
  export declare function resolveOutdir(config: ExtensionConfig, target: ExtensionTarget, override?: string): string;
4
+ /** Rewrite promise-style `chrome.*` API access in `code` to `browser.*`. */
5
+ export declare function rewriteBrowserNamespace(code: string): { code: string, replacements: number };
4
6
  /**`) + extra `assets` copied in
5
7
  * - stx pages → `<name>.html` (+ companion `<name>.js`), sanitized for the
6
8
  * extension CSP, with stx dev chunks removed
package/dist/build.js CHANGED
@@ -11,7 +11,14 @@ export function resolveOutdir(config, target, override) {
11
11
  return config.outdir;
12
12
  if (config.outdir?.[target])
13
13
  return config.outdir[target];
14
- return target === "firefox" ? "dist-firefox" : "dist";
14
+ return target === "chrome" ? "dist" : `dist-${target}`;
15
+ }
16
+ const browserApiNamespaces = ["runtime", "tabs", "declarativeNetRequest", "storage", "action", "alarms", "scripting", "webNavigation", "cookies", "contextMenus", "notifications", "i18n", "downloads", "permissions", "windows", "bookmarks", "history", "search", "sidePanel", "omnibox", "idle", "management", "commands", "sessions", "topSites", "extension", "types", "devtools", "offscreen", "clipboard"], browserNamespacePattern = new RegExp(`\\bchrome\\.(?=(?:${browserApiNamespaces.join("|")})[?.])`, "g");
17
+ export function rewriteBrowserNamespace(code) {
18
+ const replacements = (code.match(browserNamespacePattern) ?? []).length;
19
+ if (!replacements)
20
+ return { code, replacements: 0 };
21
+ return { code: code.replace(browserNamespacePattern, "browser."), replacements };
15
22
  }
16
23
  function normalizePage(page) {
17
24
  return typeof page === "string" ? { template: page } : page;
@@ -88,6 +95,12 @@ export async function buildExtension(config, options) {
88
95
  for (const cs of config.content ?? [])
89
96
  scripts.push({ entry: cs.entry, out: contentScriptOut(cs.entry, cs.out) });
90
97
  await Promise.all(scripts.map((s) => buildScript(s.entry, s.out, outdir, cwd, minify)));
98
+ if (target === "safari")
99
+ for await (const file of new Glob("*.js").scan(outdir)) {
100
+ const path = join(outdir, file), { code, replacements } = rewriteBrowserNamespace(await Bun.file(path).text());
101
+ if (replacements)
102
+ await Bun.write(path, code);
103
+ }
91
104
  for (const rule of config.rules ?? []) {
92
105
  if (!rule.source)
93
106
  continue;
package/dist/index.d.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * @stacksjs/browser-extension
3
3
  *
4
- * Build MV3 browser extensions (Chrome + Firefox) from a single
4
+ * Build MV3 browser extensions (Chrome, Firefox, Safari) from a single
5
5
  * `ExtensionConfig` — manifest generation, content/background script bundling,
6
6
  * stx pages, declarativeNetRequest rulesets, and store-ready packaging — with
7
- * none of the per-project boilerplate.
7
+ * none of the per-project boilerplate. Safari additionally gets the container
8
+ * app scaffold, appex resource sync, and xcodebuild pipeline.
8
9
  */
9
10
  export * from './build';
10
11
  export * from './config';
11
12
  export * from './manifest';
12
13
  export * from './package';
14
+ export * from './safari';
13
15
  export * from './sanitize';
14
16
  export * from './types';
package/dist/index.js CHANGED
@@ -2,5 +2,6 @@ export * from "./build";
2
2
  export * from "./config";
3
3
  export * from "./manifest";
4
4
  export * from "./package";
5
+ export * from "./safari";
5
6
  export * from "./sanitize";
6
7
  export * from "./types";
@@ -4,7 +4,10 @@ export declare function contentScriptOut(entry: string, out?: string): string;
4
4
  /**
5
5
  * Generate an MV3 manifest for a target from the extension config. Chrome uses
6
6
  * a `service_worker` background + `minimum_chrome_version`; Firefox uses a
7
- * `scripts` event page + `browser_specific_settings.gecko` (required by AMO).
7
+ * `scripts` event page + `browser_specific_settings.gecko` (required by AMO);
8
+ * Safari uses a classic (non-module) `service_worker` +
9
+ * `browser_specific_settings.safari` (15.4+ runs MV3 service workers, and the
10
+ * bundles are classic IIFEs, so the module hint is dropped).
8
11
  */
9
12
  export declare function generateManifest(config: ExtensionConfig, opts: { version: string, target?: ExtensionTarget }): GeneratedManifest;
10
13
  /** MV3 manifest shape (loose — the two targets diverge on `background`). */
@@ -23,6 +26,10 @@ export declare interface GeneratedManifest {
23
26
  strict_min_version: string
24
27
  data_collection_permissions: { required: ['none'] }
25
28
  }
29
+ } | {
30
+ safari: {
31
+ strict_min_version: string
32
+ }
26
33
  }
27
34
  permissions?: string[]
28
35
  optional_permissions?: string[]
package/dist/manifest.js CHANGED
@@ -3,20 +3,20 @@ export function contentScriptOut(entry, out) {
3
3
  return out ?? basename(entry).replace(/\.[cm]?tsx?$/, ".js");
4
4
  }
5
5
  export function generateManifest(config, opts) {
6
- const isFirefox = (opts.target ?? "chrome") === "firefox", m = config.manifest ?? {}, manifest = {
6
+ const target = opts.target ?? "chrome", isFirefox = target === "firefox", isSafari = target === "safari", m = config.manifest ?? {}, manifest = {
7
7
  manifest_version: 3,
8
8
  name: config.name,
9
9
  description: config.description,
10
10
  version: opts.version
11
11
  };
12
- if (!isFirefox && m.minimumChromeVersion)
12
+ if (!isFirefox && !isSafari && m.minimumChromeVersion)
13
13
  manifest.minimum_chrome_version = m.minimumChromeVersion;
14
14
  if (config.pages?.popup)
15
15
  manifest.action = { default_title: config.name, default_popup: "popup.html" };
16
16
  if (config.pages?.options)
17
17
  manifest.options_page = "options.html";
18
18
  if (config.background)
19
- manifest.background = isFirefox ? { scripts: ["background.js"], type: "module" } : { service_worker: "background.js", type: "module" };
19
+ manifest.background = isFirefox ? { scripts: ["background.js"], type: "module" } : isSafari ? { service_worker: "background.js" } : { service_worker: "background.js", type: "module" };
20
20
  if (isFirefox && config.geckoId)
21
21
  manifest.browser_specific_settings = {
22
22
  gecko: {
@@ -25,6 +25,12 @@ export function generateManifest(config, opts) {
25
25
  data_collection_permissions: { required: ["none"] }
26
26
  }
27
27
  };
28
+ if (isSafari)
29
+ manifest.browser_specific_settings = {
30
+ safari: {
31
+ strict_min_version: m.safariMinVersion ?? "18.4"
32
+ }
33
+ };
28
34
  if (m.permissions?.length)
29
35
  manifest.permissions = m.permissions;
30
36
  if (m.optionalPermissions?.length)
package/dist/package.js CHANGED
@@ -12,7 +12,7 @@ export async function packageExtension(config, options) {
12
12
  const outdir = resolve(cwd, resolveOutdir(config, target));
13
13
  if (!existsSync(outdir))
14
14
  throw Error(`[browser-extension] nothing to package: ${outdir} does not exist`);
15
- const suffix = target === "firefox" ? "-firefox" : "", outfile = resolve(cwd, options.outfile ?? `${slug(config.name)}-${options.version}${suffix}.zip`);
15
+ const suffix = target === "chrome" ? "" : `-${target}`, outfile = resolve(cwd, options.outfile ?? `${slug(config.name)}-${options.version}${suffix}.zip`);
16
16
  await mkdir(dirname(outfile), { recursive: !0 });
17
17
  const proc = Bun.spawn(["zip", "-r", "-q", outfile, "."], { cwd: outdir, stdout: "pipe", stderr: "pipe" }), code = await proc.exited;
18
18
  if (code !== 0)
@@ -0,0 +1,79 @@
1
+ import type { ExtensionConfig } from './types';
2
+ /**
3
+ * Safari container-app tooling: scaffold the Xcode project, sync the built
4
+ * safari bundle into the appex Resources, and xcodebuild the app.
5
+ *
6
+ * Safari Web Extensions ship inside a macOS app, so the safari target has two
7
+ * halves: the web bundle (`extension:build --target safari`, `dist-safari/`)
8
+ * and the container app scaffolded here (a checked-in, converter-free Xcode
9
+ * project: SwiftUI app + Safari Web Extension appex).
10
+ *
11
+ * The template tree lives in `safari-template/` at the package root and is
12
+ * tokenized (`__APP_NAME__`, `__BUNDLE_ID__`, …); scaffolding copies it and
13
+ * substitutes per project. It mirrors what `xcrun
14
+ * safari-web-extension-converter` generates, so day-to-day work never needs
15
+ * the converter.
16
+ */
17
+ /** App target name from the extension display name (`Very Good AdBlock` → `VeryGoodAdBlock`). */
18
+ export declare function safariAppName(config: ExtensionConfig): string;
19
+ /** Directory the container app is scaffolded into. */
20
+ export declare function safariProjectDir(cwd?: unknown, dir?: string): string;
21
+ /**
22
+ * Scaffold the macOS container app (Xcode project + SwiftUI app + appex) from
23
+ * the tokenized template. Idempotent: existing files are kept unless `force`.
24
+ * Also renders the AppIcon/ExtensionIcon PNGs from the project's own icons
25
+ * via `sips` (macOS-only step; skipped with a warning elsewhere).
26
+ */
27
+ export declare function scaffoldSafariApp(config: ExtensionConfig, options?: SafariScaffoldOptions): Promise<{ dir: string, written: string[], skipped: string[] }>;
28
+ /**
29
+ * Mirror the built safari bundle into the appex `Resources/` folder. The
30
+ * project references Resources as a folder, so everything synced here ships
31
+ * in the appex verbatim. Files listed in `config.safariExclude` (e.g.
32
+ * marketing-site pages built into dist) are kept out.
33
+ */
34
+ export declare function syncSafariResources(config: ExtensionConfig, options?: SafariSyncOptions): Promise<{ resources: string, files: number }>;
35
+ /**
36
+ * Full pipeline: build the safari bundle, sync it into the appex Resources,
37
+ * then xcodebuild the container app when full Xcode is available. Returns the
38
+ * built `.app` path (undefined when xcodebuild was skipped/unavailable).
39
+ */
40
+ export declare function buildSafariApp(config: ExtensionConfig, options?: SafariAppBuildOptions): Promise<{ appPath?: string, resources: string }>;
41
+ /**
42
+ * Create a signed Release archive and either validate it or upload it to App
43
+ * Store Connect. Xcode owns certificate/profile creation and the upload so the
44
+ * same command works locally and in macOS CI with an API key.
45
+ */
46
+ export declare function publishSafariApp(config: ExtensionConfig, options: SafariPublishOptions): Promise<{ archivePath: string, exportPath: string, buildNumber: string }>;
47
+ export declare interface SafariScaffoldOptions {
48
+ bundleId?: string
49
+ dir?: string
50
+ force?: boolean
51
+ version?: string
52
+ iconsDir?: string
53
+ teamId?: string
54
+ cwd?: string
55
+ }
56
+ export declare interface SafariSyncOptions {
57
+ outdir?: string
58
+ dir?: string
59
+ cwd?: string
60
+ }
61
+ export declare interface SafariAppBuildOptions extends SafariSyncOptions {
62
+ release?: boolean
63
+ signed?: boolean
64
+ skipXcodebuild?: boolean
65
+ version?: string
66
+ build?: boolean
67
+ }
68
+ export declare interface AppStoreConnectAuth {
69
+ keyId?: string
70
+ issuerId?: string
71
+ keyPath?: string
72
+ }
73
+ export declare interface SafariPublishOptions extends SafariSyncOptions, AppStoreConnectAuth {
74
+ version: string
75
+ buildNumber?: string
76
+ teamId?: string
77
+ validateOnly?: boolean
78
+ build?: boolean
79
+ }
package/dist/safari.js ADDED
@@ -0,0 +1,199 @@
1
+ import { cpSync, existsSync, readdirSync, statSync } from "node:fs";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { buildExtension, resolveOutdir } from "./build";
5
+ export function safariAppName(config) {
6
+ const name = config.name.replace(/[^a-z0-9]+/gi, "");
7
+ if (!name)
8
+ throw Error("[browser-extension] cannot derive an app name from the extension name; set one without spaces");
9
+ return name;
10
+ }
11
+ export function safariProjectDir(cwd = process.cwd(), dir = "safari") {
12
+ return resolve(cwd, dir);
13
+ }
14
+ function templateRoot() {
15
+ return resolve(import.meta.dir, "..", "safari-template");
16
+ }
17
+ function fillTemplate(text, vars) {
18
+ return text.replaceAll("__EXT_BUNDLE_ID__", vars.extBundleId).replaceAll("__BUNDLE_ID__", vars.bundleId).replaceAll("__APP_DISPLAY_NAME__", vars.displayName).replaceAll("__APP_NAME__", vars.appName).replaceAll("__MARKETING_VERSION__", vars.version).replaceAll("__DEVELOPMENT_TEAM__", vars.teamId).replaceAll("__YEAR__", String(new Date().getFullYear()));
19
+ }
20
+ function* walk(dir, prefix = "") {
21
+ for (const entry of readdirSync(dir)) {
22
+ const rel = prefix ? `${prefix}/${entry}` : entry;
23
+ if (statSync(join(dir, entry)).isDirectory())
24
+ yield* walk(join(dir, entry), rel);
25
+ else
26
+ yield rel;
27
+ }
28
+ }
29
+ export async function scaffoldSafariApp(config, options = {}) {
30
+ const cwd = options.cwd ?? process.cwd(), appName = safariAppName(config), bundleId = options.bundleId ?? config.safariBundleId ?? `com.example.${appName.toLowerCase()}`, dir = safariProjectDir(cwd, options.dir), vars = {
31
+ appName,
32
+ displayName: config.name,
33
+ bundleId,
34
+ extBundleId: `${bundleId}.Extension`,
35
+ version: options.version ?? "0.1.0",
36
+ teamId: options.teamId ?? config.safariTeamId ?? ""
37
+ };
38
+ if (!options.bundleId && !config.safariBundleId)
39
+ console.warn(`[browser-extension] no safari bundle id configured; using placeholder ${bundleId}. Set safariBundleId in config/extension.ts.`);
40
+ const written = [], skipped = [];
41
+ for (const rel of walk(templateRoot())) {
42
+ const outRel = fillTemplate(rel, vars), out = join(dir, outRel);
43
+ if (existsSync(out) && !options.force) {
44
+ skipped.push(outRel);
45
+ continue;
46
+ }
47
+ await mkdir(dirname(out), { recursive: !0 });
48
+ await Bun.write(out, fillTemplate(await Bun.file(join(templateRoot(), rel)).text(), vars));
49
+ written.push(outRel);
50
+ }
51
+ await generateAppIcons(config, dir, vars, options.iconsDir, cwd);
52
+ return { dir, written, skipped };
53
+ }
54
+ const appIconSizes = [16, 32, 64, 128, 256, 512, 1024];
55
+ async function generateAppIcons(config, dir, vars, iconsDir, cwd) {
56
+ const source = [
57
+ iconsDir,
58
+ join(resolve(cwd, resolveOutdir(config, "chrome")), "icons"),
59
+ config.public ? join(resolve(cwd, config.public), "icons") : void 0
60
+ ].filter(Boolean).find((d) => existsSync(d));
61
+ if (!source) {
62
+ console.warn("[browser-extension] no icons directory found; add PNGs to the asset catalog manually.");
63
+ return;
64
+ }
65
+ let largest, largestSize = 0;
66
+ for (const file of readdirSync(source)) {
67
+ const match = /(\d+)\.png$/.exec(file);
68
+ if (match && Number(match[1]) > largestSize) {
69
+ largestSize = Number(match[1]);
70
+ largest = join(source, file);
71
+ }
72
+ }
73
+ if (!largest) {
74
+ console.warn(`[browser-extension] no icon PNGs in ${source}; add them to the asset catalog manually.`);
75
+ return;
76
+ }
77
+ const appIconDir = join(dir, vars.appName, "Assets.xcassets", "AppIcon.appiconset"), imageSetDir = join(dir, vars.appName, "Assets.xcassets", "ExtensionIcon.imageset");
78
+ await mkdir(appIconDir, { recursive: !0 });
79
+ await mkdir(imageSetDir, { recursive: !0 });
80
+ const targets = [
81
+ ...appIconSizes.map((size) => ({ dir: appIconDir, name: `icon-${size}.png`, size })),
82
+ { dir: imageSetDir, name: "extension-icon-128.png", size: 128 },
83
+ { dir: imageSetDir, name: "extension-icon-256.png", size: 256 }
84
+ ];
85
+ for (const t of targets)
86
+ if (await Bun.spawn(["sips", "-z", String(t.size), String(t.size), largest, "--out", join(t.dir, t.name)], { stdout: "ignore", stderr: "pipe" }).exited !== 0) {
87
+ console.warn(`[browser-extension] sips failed for ${t.name} (macOS only). Generate the icon PNGs on a Mac.`);
88
+ return;
89
+ }
90
+ }
91
+ export async function syncSafariResources(config, options = {}) {
92
+ const cwd = options.cwd ?? process.cwd(), outdir = resolve(cwd, options.outdir ?? resolveOutdir(config, "safari"));
93
+ if (!existsSync(join(outdir, "manifest.json")))
94
+ throw Error(`[browser-extension] ${outdir}/manifest.json is missing. Run extension:build --target safari first.`);
95
+ const appName = safariAppName(config), resources = join(safariProjectDir(cwd, options.dir), `${appName} Extension`, "Resources"), exclude = new Set(config.safariExclude ?? []);
96
+ if (existsSync(resources)) {
97
+ for (const entry of readdirSync(resources))
98
+ if (entry !== ".gitkeep")
99
+ await rm(join(resources, entry), { recursive: !0, force: !0 });
100
+ }
101
+ await mkdir(resources, { recursive: !0 });
102
+ let files = 0;
103
+ for (const rel of walk(outdir)) {
104
+ if (exclude.has(rel))
105
+ continue;
106
+ const dest = join(resources, rel);
107
+ await mkdir(dirname(dest), { recursive: !0 });
108
+ cpSync(join(outdir, rel), dest);
109
+ files += 1;
110
+ }
111
+ return { resources, files };
112
+ }
113
+ export async function buildSafariApp(config, options = {}) {
114
+ const cwd = options.cwd ?? process.cwd();
115
+ if (options.build !== !1) {
116
+ if (!options.version)
117
+ throw Error("[browser-extension] buildSafariApp needs a version to build the extension");
118
+ await buildExtension(config, { target: "safari", version: options.version, cwd });
119
+ }
120
+ const { resources } = await syncSafariResources(config, options);
121
+ if (options.skipXcodebuild)
122
+ return { resources };
123
+ const appName = safariAppName(config), dir = safariProjectDir(cwd, options.dir), configuration = options.release ? "Release" : "Debug", derivedData = join(dir, "build"), developerDir = (process.env.DEVELOPER_DIR ?? (await Bun.$`xcode-select -p`.text()).trim()).replace(/\/$/, "");
124
+ if (!developerDir.includes(".app/Contents/Developer")) {
125
+ console.error(`[browser-extension] full Xcode is required to build the app (active developer directory: ${developerDir}).`);
126
+ console.error("Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer");
127
+ console.error("or point DEVELOPER_DIR at any Xcode toolchain (betas included) for this shell.");
128
+ console.error(`The extension payload is built and synced at ${resources}`);
129
+ return { resources };
130
+ }
131
+ const signing = options.signed ? ["-allowProvisioningUpdates"] : ["CODE_SIGNING_ALLOWED=NO"];
132
+ await Bun.$`xcodebuild -project ${join(dir, `${appName}.xcodeproj`)} -scheme ${appName} -configuration ${configuration} -derivedDataPath ${derivedData} ${signing} build`;
133
+ return { appPath: join(derivedData, "Build", "Products", configuration, `${appName}.app`), resources };
134
+ }
135
+ function appStoreConnectAuth(options) {
136
+ const keyId = options.keyId ?? process.env.APP_STORE_CONNECT_API_KEY_ID, issuerId = options.issuerId ?? process.env.APP_STORE_CONNECT_API_ISSUER_ID, keyPath = options.keyPath ?? process.env.APP_STORE_CONNECT_API_KEY_PATH, missing = [
137
+ !keyId && "APP_STORE_CONNECT_API_KEY_ID",
138
+ !issuerId && "APP_STORE_CONNECT_API_ISSUER_ID",
139
+ !keyPath && "APP_STORE_CONNECT_API_KEY_PATH"
140
+ ].filter(Boolean);
141
+ if (missing.length)
142
+ throw Error(`[browser-extension] missing App Store Connect credentials: ${missing.join(", ")}`);
143
+ if (!existsSync(resolve(keyPath)))
144
+ throw Error(`[browser-extension] App Store Connect API key not found: ${resolve(keyPath)}`);
145
+ return { keyId, issuerId, keyPath: resolve(keyPath) };
146
+ }
147
+ function xcodeAuthArgs(auth) {
148
+ return [
149
+ "-allowProvisioningUpdates",
150
+ "-authenticationKeyPath",
151
+ auth.keyPath,
152
+ "-authenticationKeyID",
153
+ auth.keyId,
154
+ "-authenticationKeyIssuerID",
155
+ auth.issuerId
156
+ ];
157
+ }
158
+ function exportOptionsPlist(method, teamId) {
159
+ return `<?xml version="1.0" encoding="UTF-8"?>
160
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
161
+ <plist version="1.0">
162
+ <dict>
163
+ <key>destination</key>
164
+ <string>upload</string>
165
+ <key>manageAppVersionAndBuildNumber</key>
166
+ <false/>
167
+ <key>method</key>
168
+ <string>${method}</string>
169
+ <key>signingStyle</key>
170
+ <string>automatic</string>
171
+ <key>teamID</key>
172
+ <string>${teamId}</string>
173
+ <key>uploadSymbols</key>
174
+ <true/>
175
+ </dict>
176
+ </plist>
177
+ `;
178
+ }
179
+ export async function publishSafariApp(config, options) {
180
+ const cwd = options.cwd ?? process.cwd(), teamId = options.teamId ?? config.safariTeamId;
181
+ if (!teamId)
182
+ throw Error("[browser-extension] Safari publishing needs safariTeamId in config/extension.ts or --team-id");
183
+ const auth = appStoreConnectAuth(options), buildNumber = options.buildNumber ?? process.env.GITHUB_RUN_NUMBER ?? String(Math.floor(Date.now() / 1000));
184
+ if (!/^\d+(?:\.\d+){0,2}$/.test(buildNumber))
185
+ throw Error(`[browser-extension] invalid Safari build number ${buildNumber}; use one to three dot-separated integers`);
186
+ if (options.build !== !1)
187
+ await buildExtension(config, { target: "safari", version: options.version, cwd });
188
+ await syncSafariResources(config, options);
189
+ 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);
190
+ await mkdir(buildDir, { recursive: !0 });
191
+ await rm(archivePath, { recursive: !0, force: !0 });
192
+ await rm(exportPath, { recursive: !0, force: !0 });
193
+ const project = join(dir, `${appName}.xcodeproj`);
194
+ 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`;
195
+ const method = options.validateOnly ? "validation" : "app-store-connect";
196
+ await Bun.write(plistPath, exportOptionsPlist(method, teamId));
197
+ await Bun.$`xcodebuild -exportArchive -archivePath ${archivePath} -exportPath ${exportPath} -exportOptionsPlist ${plistPath} ${authArgs}`;
198
+ return { archivePath, exportPath, buildNumber };
199
+ }
package/dist/types.d.ts CHANGED
@@ -39,6 +39,7 @@ export declare interface ManifestOverrides {
39
39
  optionalPermissions?: string[]
40
40
  minimumChromeVersion?: string
41
41
  firefoxMinVersion?: string
42
+ safariMinVersion?: string
42
43
  contentSecurityPolicy?: string
43
44
  webAccessibleResources?: Array<{ resources: string[], matches: string[] }>
44
45
  extra?: Record<string, unknown>
@@ -55,6 +56,9 @@ export declare interface ExtensionConfig {
55
56
  name: string
56
57
  description: string
57
58
  geckoId?: string
59
+ safariBundleId?: string
60
+ safariTeamId?: string
61
+ safariExclude?: string[]
58
62
  targets?: ExtensionTarget[]
59
63
  background?: string
60
64
  content?: ContentScript[]
@@ -84,4 +88,4 @@ export declare interface BuildOptions {
84
88
  * manifest, build graph, and packaging from it — no hand-written manifest.json
85
89
  * or per-project build script.
86
90
  */
87
- export type ExtensionTarget = 'chrome' | 'firefox';
91
+ export type ExtensionTarget = 'chrome' | 'firefox' | 'safari';
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@stacksjs/browser-extension",
3
3
  "type": "module",
4
- "version": "0.70.113",
5
- "description": "Build MV3 browser extensions (Chrome + Firefox) the Stacks way — manifest, content/background scripts, DNR rules, packaging, all config-driven.",
4
+ "version": "0.70.114",
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": [
8
8
  "Chris Breuer <chris@stacksjs.com>"
@@ -22,6 +22,7 @@
22
22
  "browser-extension",
23
23
  "chrome-extension",
24
24
  "firefox-extension",
25
+ "safari-extension",
25
26
  "manifest-v3",
26
27
  "mv3",
27
28
  "declarativeNetRequest",
@@ -48,7 +49,8 @@
48
49
  "types": "dist/index.d.ts",
49
50
  "files": [
50
51
  "README.md",
51
- "dist"
52
+ "dist",
53
+ "safari-template"
52
54
  ],
53
55
  "scripts": {
54
56
  "build": "bun build.ts",
@@ -56,6 +58,6 @@
56
58
  "prepublishOnly": "bun run build"
57
59
  },
58
60
  "devDependencies": {
59
- "better-dx": "^0.2.16"
61
+ "better-dx": "catalog:"
60
62
  }
61
63
  }
@@ -0,0 +1,63 @@
1
+ # __APP_DISPLAY_NAME__ for Safari
2
+
3
+ The macOS container app that ships the Safari Web Extension build of
4
+ __APP_DISPLAY_NAME__. Generated by `buddy extension:safari:init`; safe to
5
+ regenerate with `--force` (your signing settings are the only thing to redo).
6
+
7
+ - `__APP_NAME__/` — the SwiftUI container app (shows extension state, opens
8
+ Safari's extension settings).
9
+ - `__APP_NAME__ Extension/` — the Safari Web Extension target. Its
10
+ `Resources/` folder is a mirror of the safari build output, produced by
11
+ `buddy extension:safari:app` (or `syncSafariResources`).
12
+ - `__APP_NAME__.xcodeproj` — checked in on purpose: building the app never
13
+ requires re-running Apple's `safari-web-extension-converter`.
14
+
15
+ ## Prerequisites
16
+
17
+ - macOS with Safari 18.4+ (the manifest pins
18
+ `browser_specific_settings.safari.strict_min_version`).
19
+ - Full Xcode (Command Line Tools alone are not enough):
20
+
21
+ ```bash
22
+ sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
23
+ xcodebuild -license accept # first launch only
24
+ ```
25
+
26
+ ## Build
27
+
28
+ ```bash
29
+ buddy extension:safari:app
30
+ ```
31
+
32
+ This builds the extension (safari target), syncs the bundle into the appex
33
+ Resources, and runs xcodebuild. Then launch __APP_NAME__.app once (it
34
+ registers the extension with Safari) and enable it under
35
+ Safari > Settings > Extensions, granting it website access.
36
+
37
+ ## Signing
38
+
39
+ The project uses `safariTeamId` when configured and otherwise leaves
40
+ `DEVELOPMENT_TEAM` empty so anyone can build.
41
+
42
+ Local, unsigned (no Apple account): the default build uses
43
+ `CODE_SIGNING_ALLOWED=NO`. Unsigned extensions require
44
+ Safari > Develop > Allow Unsigned Extensions (resets on Safari updates).
45
+
46
+ Signed (Apple Developer Program): select your team under Signing &
47
+ Capabilities for both targets, then:
48
+
49
+ ```bash
50
+ buddy extension:safari:app --signed # Debug, Apple Development identity
51
+ buddy extension:safari:app --signed --release # Release
52
+ ```
53
+
54
+ ## Distribution
55
+
56
+ - Mac App Store: `xcodebuild archive` (or Xcode > Product > Archive), then
57
+ Xcode Organizer > Distribute App > App Store Connect.
58
+ - Developer ID + notarization: archive, export with a Developer ID profile,
59
+ then `xcrun notarytool submit ... --wait && xcrun stapler staple
60
+ __APP_NAME__.app`. The project already enables the hardened runtime.
61
+
62
+ Either way, bump `MARKETING_VERSION` in the project when the extension
63
+ releases.