diff --git a/packages/wxt-demo/package.json b/packages/wxt-demo/package.json index 7d2ca8dfb..82c9d2648 100644 --- a/packages/wxt-demo/package.json +++ b/packages/wxt-demo/package.json @@ -7,6 +7,7 @@ "dev": "buildc --deps-only -- wxt", "dev:firefox": "buildc --deps-only -- wxt -b firefox", "dev:firefox-mv3": "buildc --deps-only -- wxt -b firefox --mv3", + "dev:firefox-android": "buildc --deps-only -- wxt -b firefox-android", "build": "buildc --deps-only -- wxt build", "build:all": "buildc --deps-only -- bun run --sequential 'build:*-*'", "build:chrome-mv3": "wxt build", diff --git a/packages/wxt/src/builtin-modules/adb.ts b/packages/wxt/src/builtin-modules/adb.ts new file mode 100644 index 000000000..c7bd136aa --- /dev/null +++ b/packages/wxt/src/builtin-modules/adb.ts @@ -0,0 +1,110 @@ +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { defineWxtModule } from '../modules'; +import { Wxt } from '../types'; + +const execFileAsync = promisify(execFile); + +/** + * Fills in `webExt.adbDevice` when a single device is connected and reverses + * the dev server port to it so the extension's `localhost:` URLs (HMR, + * dev-mode pages) work there. + */ +export default defineWxtModule({ + name: 'wxt:built-in:adb', + setup(wxt) { + let removeAdbReverse: (() => Promise) | undefined; + let detectedDevice: string | undefined; + + wxt.hooks.hook('config:resolved', async (wxt) => { + const config = wxt.config.webExt.config; + if (config?.target !== 'firefox-android' || config.adbDevice) return; + detectedDevice ??= await findSingleAdbDevice(wxt, config.adbBin ?? 'adb'); + config.adbDevice = detectedDevice; + }); + wxt.hooks.hook('server:started', async (wxt, server) => { + if (wxt.config.webExt.config?.target !== 'firefox-android') return; + removeAdbReverse = await setupAdbReverse(wxt, server.port); + }); + wxt.hooks.hook('server:closed', async () => { + await removeAdbReverse?.(); + removeAdbReverse = undefined; + }); + }, +}); + +/** + * When `adbDevice` isn't configured and exactly one device is connected, use + * it. Otherwise return undefined and let web-ext report the device list. + */ +async function findSingleAdbDevice( + wxt: Wxt, + adbBin: string, +): Promise { + try { + const { stdout } = await execFileAsync(adbBin, ['devices']); + const devices = stdout + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length === 2 && parts[1] === 'device') + .map((parts) => parts[0]); + if (devices.length === 1) { + wxt.logger.info(`Using ADB device: ${devices[0]}`); + return devices[0]; + } + wxt.logger.warn( + `Expected exactly 1 connected ADB device, found ${devices.length}. Set \`webExt.adbDevice\` to pick one.`, + ); + } catch (err) { + wxt.logger.warn( + `Failed to list ADB devices with "${adbBin} devices", set \`webExt.adbDevice\` manually`, + err, + ); + } + return undefined; +} + +async function setupAdbReverse( + wxt: Wxt, + port: number, +): Promise<(() => Promise) | undefined> { + const { adbBin = 'adb', adbDevice } = wxt.config.webExt.config ?? {}; + // Without `-s`, adb targets the only connected device + const deviceArgs = adbDevice ? ['-s', adbDevice] : []; + const portSpec = `tcp:${port}`; + try { + await execFileAsync(adbBin, [...deviceArgs, 'reverse', portSpec, portSpec]); + wxt.logger.info(`Reversed dev server port ${port} to device for HMR`); + // Ctrl+C doesn't go through server.stop(), clean up on process death too + const removeSync = () => { + try { + execFileSync(adbBin, [...deviceArgs, 'reverse', '--remove', portSpec], { + stdio: 'ignore', + }); + } catch {} + }; + const onSignal = (signal: NodeJS.Signals) => { + removeSync(); + try { + process.kill(process.pid, signal); + } catch { + process.exit(130); + } + }; + process.once('exit', removeSync); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + return async () => { + process.removeListener('exit', removeSync); + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + removeSync(); + }; + } catch (err) { + wxt.logger.warn( + `Failed to run "adb reverse" for port ${port}, hot reload will not work on the device`, + err, + ); + } +} diff --git a/packages/wxt/src/builtin-modules/index.ts b/packages/wxt/src/builtin-modules/index.ts index de2510ec5..ac9c7bd5a 100644 --- a/packages/wxt/src/builtin-modules/index.ts +++ b/packages/wxt/src/builtin-modules/index.ts @@ -2,9 +2,11 @@ import { WxtModule } from '../types'; import faviconPermission from './favicon-permission'; import unimport from './unimport'; import escapeUnicode from './escape-unicode'; +import adb from './adb'; export const builtinModules: WxtModule[] = [ unimport, faviconPermission, escapeUnicode, + adb, ]; diff --git a/packages/wxt/src/core/resolve-config.ts b/packages/wxt/src/core/resolve-config.ts index c74ffbbb8..ba9f1e566 100644 --- a/packages/wxt/src/core/resolve-config.ts +++ b/packages/wxt/src/core/resolve-config.ts @@ -72,24 +72,43 @@ export async function resolveConfig( const logger = createWxtLogger(mergedConfig.logger ?? consola); if (debug) logger.level = LogLevels.debug; - const browser = mergedConfig.browser ?? 'chrome'; + const root = path.resolve( + inlineConfig.root ?? userConfig.root ?? process.cwd(), + ); + const webExt = await loadConfig({ + name: 'web-ext', + cwd: root, + globalRc: true, + rcFile: '.webextrc', + overrides: inlineConfig.webExt, + defaults: userConfig.webExt, + }); + + // "firefox-android" is an alias for the firefox build targeting an Android device + const requestedBrowser = mergedConfig.browser ?? 'chrome'; + const isAndroid = + requestedBrowser === 'firefox-android' || + webExt.config?.target === 'firefox-android'; + const browser = + requestedBrowser === 'firefox-android' ? 'firefox' : requestedBrowser; + if (isAndroid) { + webExt.config = { ...webExt.config, target: 'firefox-android' }; + } const targetBrowsers = mergedConfig.targetBrowsers ?? []; if (targetBrowsers.length > 0 && !targetBrowsers.includes(browser)) { throw new Error( `Current target browser \`${browser}\` is not in your \`targetBrowsers\` list!`, ); } + // Android must be MV3: dev-mode content script reload is MV3-only const manifestVersion = mergedConfig.manifestVersion ?? - (browser === 'firefox' || browser === 'safari' ? 2 : 3); + (isAndroid || (browser !== 'firefox' && browser !== 'safari') ? 3 : 2); const mode = mergedConfig.mode ?? COMMAND_MODES[command]; const env: ConfigEnv = { browser, command, manifestVersion, mode }; loadEnv(mode, browser); // Load any environment variables used below - const root = path.resolve( - inlineConfig.root ?? userConfig.root ?? process.cwd(), - ); const wxtDir = path.resolve(root, '.wxt'); const wxtModuleDir = resolveWxtModuleDir(); const srcDir = path.resolve(root, mergedConfig.srcDir ?? root); @@ -125,14 +144,6 @@ export async function resolveConfig( const outDir = path.resolve(outBaseDir, outDirTemplate); const reloadCommand = mergedConfig.dev?.reloadCommand ?? 'Alt+R'; - const webExt = await loadConfig({ - name: 'web-ext', - cwd: root, - globalRc: true, - rcFile: '.webextrc', - overrides: inlineConfig.webExt, - defaults: userConfig.webExt, - }); // Make sure alias are absolute const alias = Object.fromEntries( Object.entries({ @@ -146,7 +157,9 @@ export async function resolveConfig( let devServerConfig: ResolvedConfig['dev']['server']; if (command === 'serve') { - const host = mergedConfig.dev?.server?.host ?? 'localhost'; + // Bind IPv4 explicitly for android, "adb reverse" can't reach IPv6-only `::1` + const host = + mergedConfig.dev?.server?.host ?? (isAndroid ? '127.0.0.1' : 'localhost'); let port = mergedConfig.dev?.server?.port; const origin = mergedConfig.dev?.server?.origin ?? 'localhost'; const strictPort = mergedConfig.dev?.server?.strictPort ?? false; diff --git a/packages/wxt/src/core/runners/web-ext.ts b/packages/wxt/src/core/runners/web-ext.ts index 90d64e4c7..7e318a0c7 100644 --- a/packages/wxt/src/core/runners/web-ext.ts +++ b/packages/wxt/src/core/runners/web-ext.ts @@ -24,43 +24,59 @@ export function createWebExtRunner(): ExtensionRunner { }; const wxtUserConfig = wxt.config.webExt.config; + const target = + wxtUserConfig?.target ?? + (wxt.config.browser === 'firefox' ? 'firefox-desktop' : 'chromium'); + + // Options for known targets, anything else passes through to web-ext as-is + let targetConfig: Record = {}; + if (target === 'firefox-desktop') { + targetConfig = { + firefox: wxtUserConfig?.binaries?.firefox, + firefoxProfile: wxtUserConfig?.firefoxProfile, + pref: wxtUserConfig?.firefoxPref, + args: wxtUserConfig?.firefoxArgs, + }; + } else if (target === 'firefox-android') { + targetConfig = { + adbDevice: wxtUserConfig?.adbDevice, + adbBin: wxtUserConfig?.adbBin, + firefoxApk: wxtUserConfig?.firefoxApk ?? 'org.mozilla.fenix', + pref: wxtUserConfig?.firefoxPref, + }; + } else if (target === 'chromium') { + targetConfig = { + chromiumBinary: wxtUserConfig?.binaries?.[wxt.config.browser], + chromiumProfile: wxtUserConfig?.chromiumProfile, + chromiumPref: defu( + wxtUserConfig?.chromiumPref, + DEFAULT_CHROMIUM_PREFS, + ), + args: [ + '--unsafely-disable-devtools-self-xss-warnings', + ...(wxtUserConfig?.chromiumArgs ?? []), + ], + }; + } + const userConfig = { browserConsole: wxtUserConfig?.openConsole, devtools: wxtUserConfig?.openDevtools, startUrl: wxtUserConfig?.startUrls, keepProfileChanges: wxtUserConfig?.keepProfileChanges, chromiumPort: wxtUserConfig?.chromiumPort, - ...(wxt.config.browser === 'firefox' - ? { - firefox: wxtUserConfig?.binaries?.firefox, - firefoxProfile: wxtUserConfig?.firefoxProfile, - pref: wxtUserConfig?.firefoxPref, - args: wxtUserConfig?.firefoxArgs, - } - : { - chromiumBinary: wxtUserConfig?.binaries?.[wxt.config.browser], - chromiumProfile: wxtUserConfig?.chromiumProfile, - chromiumPref: defu( - wxtUserConfig?.chromiumPref, - DEFAULT_CHROMIUM_PREFS, - ), - args: [ - '--unsafely-disable-devtools-self-xss-warnings', - ...(wxtUserConfig?.chromiumArgs ?? []), - ], - }), + ...targetConfig, }; const finalConfig = { ...userConfig, - target: - wxt.config.browser === 'firefox' ? 'firefox-desktop' : 'chromium', + target, sourceDir: wxt.config.outDir, // Don't add a "Reload Manager" extension alongside dev extension, WXT // already handles reloads internally. noReloadManagerExtension: true, - // WXT handles reloads, so disable auto-reload behaviors in web-ext - noReload: true, + // WXT handles reloads, except on Android where web-ext must re-push + noReload: target !== 'firefox-android', noInput: true, }; const options = { diff --git a/packages/wxt/src/core/utils/building/find-entrypoints.ts b/packages/wxt/src/core/utils/building/find-entrypoints.ts index 0d1c27d3c..0ab9ed1c0 100644 --- a/packages/wxt/src/core/utils/building/find-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/find-entrypoints.ts @@ -157,7 +157,7 @@ export async function findEntrypoints(): Promise { .filter((item) => item.skipped) .map((item) => item.name); if (skippedEntrypointNames.length) { - wxt.logger.warn( + wxt.logger.warnOnce( [ 'The following entrypoints have been skipped:', ...skippedEntrypointNames.map( diff --git a/packages/wxt/src/core/utils/manifest.ts b/packages/wxt/src/core/utils/manifest.ts index b39ad4fc7..96eafd28a 100644 --- a/packages/wxt/src/core/utils/manifest.ts +++ b/packages/wxt/src/core/utils/manifest.ts @@ -361,7 +361,7 @@ function addEntrypoints( if (sandboxes?.length) { if (wxt.config.browser === 'firefox') { - wxt.logger.warn( + wxt.logger.warnOnce( 'Sandboxed pages not supported by Firefox. sandbox.pages was not added to the manifest', ); } else { diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 6331bd028..ac4f1ce7b 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -1253,6 +1253,14 @@ export interface WebExtConfig { startUrls?: string[]; /** @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#keep-profile-changes */ keepProfileChanges?: boolean; + /** @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#target */ + target?: 'firefox-desktop' | 'firefox-android' | 'chromium' | (string & {}); + /** @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#adb-device */ + adbDevice?: string; + /** @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#adb-bin */ + adbBin?: string; + /** @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#firefox-apk */ + firefoxApk?: string; } export interface WxtBuilder { diff --git a/packages/wxt/src/virtual/background-entrypoint.ts b/packages/wxt/src/virtual/background-entrypoint.ts index b0206e0bb..f191bcfed 100644 --- a/packages/wxt/src/virtual/background-entrypoint.ts +++ b/packages/wxt/src/virtual/background-entrypoint.ts @@ -29,7 +29,7 @@ if (import.meta.env.COMMAND === 'serve') { logger.error('Failed to setup web socket connection with dev server', err); } - browser.commands.onCommand.addListener((command) => { + browser.commands?.onCommand.addListener((command) => { if (command === 'wxt:reload-extension') { browser.runtime.reload(); }