Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 39 additions & 23 deletions build/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,34 +157,50 @@ async function handleManifest(srcPath, dstPath) {
const srcData = await fs.promises.readFile(srcPath, 'utf-8')
const data = JSON.parse(srcData)

// Remove unsupported keys
// --- Manifest V3 ---
data.manifest_version = 3

// Firefox-only top-level keys
delete data.page_action
delete data.browser_specific_settings

// Reset commands
for (const key of Object.keys(data.commands)) {
const cmd = data.commands[key]
if (key === '_execute_sidebar_action') {
cmd.suggested_key.windows = cmd.suggested_key.default
} else {
delete cmd.suggested_key
}
// Sidebar -> side panel (Chrome 114+)
data.side_panel = { default_path: data.sidebar_action.default_panel.replace(/^\.\//, '') }
delete data.sidebar_action

// Background page -> service worker (ESM). Runtime port to SW is a later phase;
// ponytail: it may throw at load for now, but the manifest is valid and installs.
data.background = { service_worker: 'bg/background.js', type: 'module' }

// browser_action -> action (drop Firefox-only default_area/theme_icons)
if (data.browser_action) {
data.action = { default_title: data.browser_action.default_title }
delete data.browser_action
}

// Clean up permissions
const contextualIdentitiesIndex = data.permissions.indexOf('contextualIdentities')
if (contextualIdentitiesIndex !== -1) data.permissions.splice(contextualIdentitiesIndex, 1)
const menusIndex = data.permissions.indexOf('menus')
if (menusIndex !== -1) data.permissions.splice(menusIndex, 1)
const menusOverrideContextIndex = data.permissions.indexOf('menus.overrideContext')
if (menusOverrideContextIndex !== -1) data.permissions.splice(menusOverrideContextIndex, 1)
const tabHideIndex = data.permissions.indexOf('tabHide')
if (tabHideIndex !== -1) data.permissions.splice(tabHideIndex, 1)
const proxyIndex = data.optional_permissions.indexOf('proxy')
if (proxyIndex !== -1) data.optional_permissions.splice(proxyIndex, 1)
data.permissions.push('proxy')

const dstData = JSON.stringify(data)
// SVG icons aren't valid in Chrome manifests; drop until PNGs are generated.
// ponytail: extension loads with a default icon; add PNG pipeline when it matters.
delete data.icons

// Permissions: keep Chrome-valid API perms, move hosts to (optional_)host_permissions.
// Dropped (no Chrome equivalent): contextualIdentities, theme, tabHide,
// menus/menus.overrideContext (contextMenus added instead), webRequestBlocking.
const KEEP = new Set([
'activeTab', 'tabs', 'cookies', 'storage', 'unlimitedStorage',
'sessions', 'search', 'identity', 'proxy',
])
const OPT_KEEP = new Set(['bookmarks', 'clipboardWrite', 'clipboardRead', 'history', 'downloads'])
data.permissions = [...(data.permissions ?? []).filter(p => KEEP.has(p)), 'sidePanel', 'contextMenus']
data.optional_permissions = (data.optional_permissions ?? []).filter(p => OPT_KEEP.has(p))
data.host_permissions = []
data.optional_host_permissions = ['<all_urls>']

// Commands: Chrome has no _execute_sidebar_action, and caps suggested keys at 4.
// ponytail: strip all suggested keys; user rebinds via chrome://extensions/shortcuts.
delete data.commands._execute_sidebar_action
for (const key of Object.keys(data.commands)) delete data.commands[key].suggested_key

const dstData = JSON.stringify(data, null, 2)
await fs.promises.writeFile(dstPath, dstData)
}

Expand Down
59 changes: 58 additions & 1 deletion build/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,57 @@ const vueComponentsPlugin = {
},
}

// Chromium build: prepend the compat-shim import as the first import of each
// entry point, so the shims run before any chunk/app code (a bare side-effect
// import is kept by esbuild, unlike `inject` which needs referenced exports).
const COMPAT_PATH = path.resolve('src/injections/browser-compat.ts')
const injectCompatPlugin = {
name: 'injectCompatPlugin',
setup: build => {
const entries = new Set(build.initialOptions.entryPoints.map(p => path.resolve(p)))
// package.json has "sideEffects": false, so the bare compat import would be
// dropped; force it to be kept.
build.onResolve({ filter: /browser-compat/ }, () => ({ path: COMPAT_PATH, sideEffects: true }))
build.onLoad({ filter: /\.ts$/ }, async args => {
if (!entries.has(args.path) || args.path === COMPAT_PATH) return
const src = await fs.promises.readFile(args.path, 'utf-8')
return { contents: `import ${JSON.stringify(COMPAT_PATH)}\n${src}`, loader: 'ts' }
})
},
}

// Chromium: define DOM globals the service worker lacks, before any chunk body
// runs (banner is prepended to every output file, incl. chunks). Foreground pages
// keep their real globals via `??=`.
//
// Query methods return null/empty (the truthful answer in a SW: no such element),
// so existing `if (!el) return` guards in foreground code bail cleanly instead of
// reaching DOM-only calls. createElement etc. return an inert node proxy so code
// that builds detached elements no-ops rather than throwing.
// ponytail: the clean fix is decoupling fg utils from the bg bundle (Phase 2).
const CHROMIUM_BANNER = `
globalThis.window ??= globalThis;
if (!globalThis.document) {
const node = new Proxy(function () {}, { get: () => node, set: () => true, apply: () => node, construct: () => node });
globalThis.document = {
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
getElementsByClassName: () => [],
getElementsByTagName: () => [],
createElement: () => node,
createElementNS: () => node,
createTextNode: () => node,
body: node, documentElement: node, head: node,
activeElement: null,
addEventListener() {}, removeEventListener() {},
};
}
globalThis.getComputedStyle ??= () => new Proxy({}, { get: () => '' });
globalThis.localStorage ??= { getItem: () => null, setItem() {}, removeItem() {}, clear() {}, key: () => null, length: 0 };
globalThis.matchMedia ??= () => ({ matches: false, media: '', onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false });
`

const PREPROC_OPTIONS = { pug: { doctype: 'html' } }
function getTemplateOptions(descriptor, bindingMetadata, filePath, fileName) {
return {
Expand Down Expand Up @@ -337,7 +388,13 @@ async function main() {
],
splitting: true,
outdir: ADDON_PATH,
plugins: [vueComponentsPlugin],
plugins: forChromium
? [vueComponentsPlugin, injectCompatPlugin]
: [vueComponentsPlugin],
// Banner lands at the top of every output file (incl. chunks), so the DOM
// globals exist before any chunk body uses them — the compat-shim chunk
// itself isn't guaranteed to load first.
banner: forChromium ? { js: CHROMIUM_BANNER } : undefined,
})
// Bundled scripts for injecting
const buildingBundledScripts = esbuild.build({
Expand Down
153 changes: 153 additions & 0 deletions build/sw-loadtest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/* eslint no-console: off */
// Self-test harness: load the built chromium service worker in Node with a
// permissive `chrome` mock, and report any error thrown during module load.
// The bundle's own banner stubs window/document/getComputedStyle. This lets us
// find SW load-time crashes without loading the extension in Helium each time.
//
// Usage: node build/sw-loadtest.js
import path from 'path'
import { pathToFileURL } from 'url'

const CONST = {
WINDOW_ID_CURRENT: -2,
WINDOW_ID_NONE: -1,
TAB_ID_NONE: -1,
}

// Deep, permissive chrome mock. Any property is a callable proxy; calls return
// sensible empties so top-level `await chrome.*` init code doesn't crash on the
// mock itself (we only care about *global* ReferenceErrors like localStorage).
// Namespaces/members that exist in Firefox but NOT Chrome — the mock returns
// undefined for these so the compat shims install exactly as they will in Helium.
const FF_ONLY_NS = new Set([
'menus', 'sidebarAction', 'browserAction', 'pageAction', 'contextualIdentities', 'theme',
])
const NESTED_ABSENT = new Set([
'setTabValue', 'getTabValue', 'removeTabValue',
'setWindowValue', 'getWindowValue', 'removeWindowValue',
'hide', 'show', 'moveInSuccession', 'warmup',
])

const handler = {
get(target, prop) {
if (prop === 'then') return undefined
if (prop in CONST) return CONST[prop]
if (prop === 'lastError') return undefined
if (typeof prop === 'symbol') return undefined
// Compat's own assignments win; real fn built-ins (.bind) come through here too.
if (prop in target) return target[prop]
if (NESTED_ABSENT.has(prop)) return undefined
const fn = (...args) => callDefault(prop, args)
const p = new Proxy(fn, handler)
target[prop] = p // memoize: stable identity + lets compat set sub-props
return p
},
set(target, prop, value) {
target[prop] = value
return true
},
apply(target, _thisArg, args) {
return target(...args)
},
}

// Root has the same behavior but hides Chrome-absent top-level namespaces.
const rootHandler = {
get(target, prop) {
if (prop in target) return target[prop] // compat aliases (e.g. menus) win
if (typeof prop === 'string' && FF_ONLY_NS.has(prop)) return undefined
return handler.get(target, prop)
},
set: handler.set,
}
function callDefault(prop, args) {
switch (prop) {
case 'getURL':
return String(args[0] ?? '')
case 'getMessage':
return ''
case 'getUILanguage':
return 'en-US'
case 'getAcceptLanguages':
return Promise.resolve(['en-US'])
case 'getManifest':
return { version: '0.0.0' }
case 'contains':
case 'request':
case 'remove': {
// Mimic Chrome rejecting Firefox-only permission names, so the compat
// permissions filter is actually exercised by the harness.
const bad = (args[0]?.permissions ?? []).find(p =>
['tabHide', 'webRequestBlocking', 'menus', 'contextualIdentities', 'theme'].includes(p)
)
if (bad) return Promise.reject(new Error(`'${bad}' is not a recognized permission.`))
return Promise.resolve(true)
}
case 'query': {
// Chrome's tabs.query rejects unknown queryInfo props like cookieStoreId.
const q = args[0] ?? {}
if ('cookieStoreId' in q) throw new Error(`Error at parameter 'queryInfo': Unexpected property: 'cookieStoreId'.`)
return Promise.resolve([])
}
case 'create':
case 'update': {
// Mimic Chrome rejecting Firefox-only props on contextMenus.create AND
// tabs.create/update (cookieStoreId), so compat sanitizers are exercised.
const props = (prop === 'update' && typeof args[0] === 'object' ? args[0] : prop === 'update' ? args[1] : args[0]) ?? {}
if ('cookieStoreId' in props) {
throw new Error(`Error at parameter 'createProperties': Unexpected property: 'cookieStoreId'.`)
}
if ('allowScriptsToClose' in props) {
throw new Error(`Error at parameter 'createData': Unexpected property: 'allowScriptsToClose'.`)
}
for (const p of ['icons', 'viewTypes', 'command', 'onclick']) {
if (p in props) throw new Error(`Error at parameter 'createProperties': Unexpected property: '${p}'.`)
}
if (props.type && !['normal', 'checkbox', 'radio', 'separator'].includes(props.type)) {
throw new Error(`Error at parameter 'createProperties': Value must be one of normal, checkbox, radio, separator.`)
}
const okCtx = ['all', 'page', 'frame', 'selection', 'link', 'editable', 'image', 'video', 'audio', 'action', 'launcher']
for (const c of props.contexts ?? []) {
if (!okCtx.includes(c)) throw new Error(`Error at parameter 'createProperties': Invalid context '${c}'.`)
}
return 'menu-id'
}
case 'get':
return Promise.resolve({})
case 'getAll':
case 'getAllInWindow':
return Promise.resolve([])
case 'getCurrent':
return Promise.resolve({})
case 'addListener':
case 'removeListener':
case 'hasListener':
return undefined
default:
return Promise.resolve(undefined)
}
}

// A real service worker has `self` (the global scope); Node doesn't.
globalThis.self ??= globalThis
globalThis.chrome = new Proxy({}, rootHandler)

// Surface unhandled async rejections during load (init is async).
process.on('unhandledRejection', err => {
console.error('UNHANDLED REJECTION during SW load:')
console.error(err)
process.exit(1)
})

const bg = pathToFileURL(path.resolve('addon/bg/background.js')).href
try {
await import(bg)
console.log('SW bundle loaded WITHOUT throwing at import time ✓')
// give async init a tick to fail if it will
await new Promise(r => setTimeout(r, 300))
console.log('No async crash within 300ms ✓')
} catch (err) {
console.error('THREW during SW load:')
console.error(err)
process.exit(1)
}
9 changes: 6 additions & 3 deletions src/dict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ export const LANG = LANG_REG.slice(0, 2)

// Set dictionary
const dict: Record<string, TranslationFn | string> = {}
if (window.translations) {
for (const key of Object.keys(window.translations)) {
const prop = window.translations[key]
const translations = (globalThis as typeof globalThis & {
translations?: Record<string, Record<string, TranslationFn | string>>
}).translations
if (translations) {
for (const key of Object.keys(translations)) {
const prop = translations[key]
dict[key] = prop[LANG_REG] ?? prop[LANG] ?? prop.en
}
}
Expand Down
Loading