@stacksjs/browser-extension 0.70.113 → 0.70.115
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 +45 -5
- package/dist/build.d.ts +2 -0
- package/dist/build.js +14 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/manifest.d.ts +8 -1
- package/dist/manifest.js +9 -3
- package/dist/package.js +1 -1
- package/dist/safari.d.ts +79 -0
- package/dist/safari.js +199 -0
- package/dist/types.d.ts +5 -1
- package/package.json +6 -4
- package/safari-template/README.md +63 -0
- package/safari-template/__APP_NAME__/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
- package/safari-template/__APP_NAME__/Assets.xcassets/Contents.json +6 -0
- package/safari-template/__APP_NAME__/Assets.xcassets/ExtensionIcon.imageset/Contents.json +22 -0
- package/safari-template/__APP_NAME__/ContentView.swift +74 -0
- package/safari-template/__APP_NAME__/Info.plist +32 -0
- package/safari-template/__APP_NAME__/__APP_NAME__.entitlements +8 -0
- package/safari-template/__APP_NAME__/__APP_NAME__App.swift +15 -0
- package/safari-template/__APP_NAME__ Extension/Info.plist +31 -0
- package/safari-template/__APP_NAME__ Extension/Resources/.gitkeep +1 -0
- package/safari-template/__APP_NAME__ Extension/SafariWebExtensionHandler.swift +32 -0
- package/safari-template/__APP_NAME__ Extension/__APP_NAME___Extension.entitlements +8 -0
- package/safari-template/__APP_NAME__.xcodeproj/project.pbxproj +490 -0
- package/safari-template/__APP_NAME__.xcodeproj/xcshareddata/xcschemes/__APP_NAME__.xcscheme +102 -0
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
|
|
4
|
-
content/background scripts, stx pages, `declarativeNetRequest`
|
|
5
|
-
store-ready packaging are all derived from a single
|
|
6
|
-
hand-written `manifest.json`, no per-project build
|
|
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 {
|
|
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 === "
|
|
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
|
|
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
package/dist/manifest.d.ts
CHANGED
|
@@ -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
|
|
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 === "
|
|
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)
|
package/dist/safari.d.ts
ADDED
|
@@ -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.
|
|
5
|
-
"description": "Build MV3 browser extensions (Chrome
|
|
4
|
+
"version": "0.70.115",
|
|
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": "
|
|
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.
|