diff --git a/addon/js/background/index.js b/addon/js/background/index.js index 350e7f7..cd34bd1 100644 --- a/addon/js/background/index.js +++ b/addon/js/background/index.js @@ -221,11 +221,6 @@ })() const actionApi = browser.browserAction || browser.action || null - if (actionApi && actionApi.onClicked) { - actionApi.onClicked.addListener(function () { - browser.runtime.openOptionsPage() - }) - } const notificationsApi = browser.notifications || null if (notificationsApi && notificationsApi.onClicked) { @@ -263,6 +258,24 @@ else shadowArms.set(key, { count: existing.count - 1, at: existing.at }) }) + /** + * Hides page actions from every open tab. + * @returns {void} + */ + function hidePageActions() { + if (!browser.pageAction) return + browser.tabs + .query({}) + .then((tabs) => + Promise.all( + tabs + .filter((tab) => tab.id != null && pendingPicker.get(tab.id)?.mode !== 'pageAction') + .map((tab) => browser.pageAction.hide(tab.id)) + ) + ) + .catch((e) => logger.debug('pageAction.hide failed', e)) + } + browser.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return let hasSiteChange = false @@ -279,6 +292,7 @@ if (hasSiteChange) refreshWorkerPolyfillSites() if (Object.keys(patch).length === 0) return settings.set(patch) + if (patch.hidePageAction) hidePageActions() }) browser.tabs.onRemoved.addListener((tabId) => { diff --git a/addon/js/background/messages.js b/addon/js/background/messages.js index d914aef..d419492 100644 --- a/addon/js/background/messages.js +++ b/addon/js/background/messages.js @@ -4,6 +4,7 @@ const http = webhid.import('http') const logger = webhid.import('logger') const isChromium = webhid.import('isChromium') + const globalSettingKey = webhid.import('globalSettingKey') const decodeDeviceCollections = webhid.import('decodeDeviceCollections') const { deviceCache, pendingPicker, permissionsPolicy, allowedCrossOrigin } = webhid.import('bgState') @@ -573,6 +574,31 @@ return false } + /** + * Shows the page action for a tab that has used the WebHID API. + * @param {object} request + * @param {object} sender + * @param {function(*): void} sendResponse + * @returns {boolean} + */ + function handleShowPageAction(request, sender, sendResponse) { + const tabId = sender.tab != null ? sender.tab.id : undefined + if (isChromium || !browser.pageAction || tabId == null) { + sendResponse({}) + return false + } + browser.storage.local + .get(globalSettingKey('hidePageAction')) + .then((values) => { + if (!values[globalSettingKey('hidePageAction')]) { + return browser.pageAction.show(tabId) + } + }) + .catch((e) => logger.debug('pageAction.show failed', e)) + .finally(() => sendResponse({})) + return true + } + /** * @param {object} request * @param {object} sender @@ -714,6 +740,32 @@ .catch(() => {}) } + /** + * Restores the page action visibility and popup after a picker closes. + * @param {number} tabId + * @returns {void} + */ + function restorePageAction(tabId) { + if (isChromium || !browser.pageAction || tabId == null) return + const key = globalSettingKey('hidePageAction') + browser.storage.local + .get(key) + .then((values) => { + const visibility = values[key] + ? browser.pageAction.hide(tabId) + : browser.pageAction.show(tabId) + return Promise.all([ + visibility, + browser.pageAction.setIcon({ tabId, path: 'icons/gamepad.svg' }), + browser.pageAction.setPopup({ + tabId, + popup: 'js/internal/pages/popup/index.html' + }) + ]) + }) + .catch((e) => logger.debug('restore pageAction failed', e)) + } + /** * Opens the picker as a pageAction popup, alerting the user via a * notification when the requesting tab is not the active one. @@ -722,17 +774,26 @@ * @param {string} origin * @returns {void} */ - function openPickerPageAction(req, tabId, origin) { + function openPickerPageAction(tabId, origin) { if (isChromium) return - browser.pageAction.setIcon({ - tabId, - path: 'icons/gamepad.alert.svg' - }) - browser.pageAction.setPopup({ - tabId, - popup: 'js/internal/pages/picker/index.html' - }) - if (browser.pageAction.openPopup) browser.pageAction.openPopup().catch(() => {}) + browser.pageAction + .show(tabId) + .then(() => + Promise.all([ + browser.pageAction.setIcon({ + tabId, + path: 'icons/gamepad.alert.svg' + }), + browser.pageAction.setPopup({ + tabId, + popup: 'js/internal/pages/picker/index.html' + }) + ]) + ) + .then(() => { + if (browser.pageAction.openPopup) return browser.pageAction.openPopup() + }) + .catch((e) => logger.debug('openPickerPageAction failed', e)) browser.tabs .query({ active: true, currentWindow: true }) .then((tabs) => { @@ -749,6 +810,27 @@ .catch(() => {}) } + /** + * Cancels a page-action picker after the requesting page times out. + * @param {object} request + * @param {object} sender + * @param {function(*): void} sendResponse + * @returns {boolean} + */ + function handleCancelPicker(request, sender, sendResponse) { + const tabId = sender.tab != null ? sender.tab.id : undefined + const req = tabId != null ? pendingPicker.get(tabId) : null + if (req && req.requestId === request.requestId) { + pendingPicker.delete(tabId) + if (req.mode === 'pageAction') { + restorePageAction(tabId) + if (browser.notifications) browser.notifications.clear('webhid-picker').catch(() => {}) + } + } + sendResponse({ ok: true }) + return false + } + /** * @param {object} request * @param {object} sender @@ -773,7 +855,7 @@ if (req.mode === 'window') { openPickerWindow() } else { - openPickerPageAction(req, tabId, request.origin) + openPickerPageAction(tabId, request.origin) } sendResponse({ ok: true }) return false @@ -915,13 +997,8 @@ if (tabId == null && pendingPicker.size > 0) tabId = [...pendingPicker.keys()][0] const req = tabId != null ? pendingPicker.get(tabId) : null if (tabId != null) pendingPicker.delete(tabId) - const reqMode = req?.mode - if (reqMode === 'pageAction' && !isChromium) { - browser.pageAction.setIcon({ tabId, path: 'icons/gamepad.svg' }) - browser.pageAction.setPopup({ - tabId, - popup: 'js/internal/pages/popup/index.html' - }) + if (req?.mode === 'pageAction' && !isChromium) { + restorePageAction(tabId) if (browser.notifications) browser.notifications.clear('webhid-picker').catch(() => {}) } if (request.windowId != null) browser.windows.remove(request.windowId).catch(() => {}) @@ -959,6 +1036,7 @@ unpairDevice: handleUnpairDevice, getAllowedDevices: handleGetAllowedDevices, deviceCountChanged: handleDeviceCountChanged, + showPageAction: handleShowPageAction, getDeviceCache: handleGetDeviceCache, getDeviceInfo: handleGetDeviceInfo, fetchResource: handleFetchResource, @@ -966,6 +1044,7 @@ getFrameOrigins: handleGetFrameOrigins, getWorkerBundle: handleGetWorkerBundle, showPicker: handleShowPicker, + cancelPicker: handleCancelPicker, getPendingPicker: handleGetPendingPicker, getPolicy: handleGetPolicy, armShadowSpawn: handleArmShadowSpawn, diff --git a/addon/js/content/isolated/bridge.js b/addon/js/content/isolated/bridge.js index 1ed43a0..1463022 100644 --- a/addon/js/content/isolated/bridge.js +++ b/addon/js/content/isolated/bridge.js @@ -110,6 +110,32 @@ 'pickerResult' ]) + const PAGE_ACTION_API_ACTIONS = new Set([ + 'getPolicy', + 'getPairedDevices', + 'enumerate', + 'requestDevice', + 'open', + 'close', + 'sendReport', + 'receiveFeatureReport', + 'sendFeatureReport', + 'unpairDevice' + ]) + let pageActionMarked = false + + /** + * Marks the current tab as using WebHID so its page action becomes visible. + * @returns {void} + */ + function markPageActionUsed() { + if (pageActionMarked || settings.hidePageAction) return + pageActionMarked = true + sendBackgroundRequest({ action: 'showPageAction' }).catch(() => { + pageActionMarked = false + }) + } + /** @type {Set} */ const openDevices = new Set() /** @type {Map>} deviceId -> origin -> LIFO @@ -332,6 +358,9 @@ /** @type {import("./types.js").SettingsStore} */ const settings = createSettingsStore(webhid.import('GLOBAL_DEFAULTS')) logger.bindSettings(settings) + settings.on('hidePageAction', (hidden) => { + if (!hidden) pageActionMarked = false + }) /** @type {Map} */ const pagePorts = new Map() /** @type {Map} */ @@ -1182,6 +1211,11 @@ }) .catch((e) => logger.debug('showPicker send failed', e)) const pickerTimeout = setTimeout(() => { + browser.runtime.onMessage.removeListener(onPickerResult) + sendBackgroundRequest({ + action: 'cancelPicker', + requestId: data.id + }).catch((e) => logger.debug('cancelPicker send failed', e)) replyToPage({ type: 'response', id: data.id, @@ -1400,6 +1434,7 @@ if (!data || data.id === undefined) return logger.debug('req action=' + data.action + ' id=' + data.id) + if (PAGE_ACTION_API_ACTIONS.has(data.action)) markPageActionUsed() const handler = REQUEST_HANDLERS[data.action] if (handler) { @@ -1561,6 +1596,7 @@ : msg.type === 'sendFeature' ? 'sendFeatureReport' : 'receiveFeatureReport' + markPageActionUsed() const payload = { deviceId, reportId: msg.reportId } if (msg.type === 'send' || msg.type === 'sendFeature') payload.data = msg.data const reqId = allocateDataReqId() diff --git a/addon/js/content/isolated/types.js b/addon/js/content/isolated/types.js index 49923bf..1d38a21 100644 --- a/addon/js/content/isolated/types.js +++ b/addon/js/content/isolated/types.js @@ -19,6 +19,7 @@ * @property {string} dataPlane * @property {number} logLevel * @property {boolean} daemonAsNmHost + * @property {boolean} hidePageAction * @property {string} devicePickerMode * @property {boolean} workerPolyfillEnabled * @property {boolean} allowActivationlessRequestDevice diff --git a/addon/js/content/main/types.js b/addon/js/content/main/types.js index 3662238..05a1a37 100644 --- a/addon/js/content/main/types.js +++ b/addon/js/content/main/types.js @@ -71,6 +71,7 @@ * @property {string} dataPlane * @property {number} logLevel * @property {boolean} daemonAsNmHost + * @property {boolean} hidePageAction * @property {string} devicePickerMode * @property {boolean} workerPolyfillEnabled * @property {boolean} allowActivationlessRequestDevice diff --git a/addon/js/internal/pages/popup/index.js b/addon/js/internal/pages/popup/index.js index ea64887..29e3b8e 100644 --- a/addon/js/internal/pages/popup/index.js +++ b/addon/js/internal/pages/popup/index.js @@ -5,6 +5,7 @@ const groupDevices = webhid.import('groupDevices') const t = webhid.import('t') const localizeHTML = webhid.import('localizeHTML') + const loadGlobalSettings = webhid.import('loadGlobalSettings') const loadEffectiveSettings = webhid.import('loadEffectiveSettings') const GLOBAL_DEFAULTS = webhid.import('GLOBAL_DEFAULTS') const settingsUi = webhid.import('settingsUi') @@ -18,6 +19,7 @@ localizeHTML(document) initInfoPopovers(document) + const globalSettings = await loadGlobalSettings() /** @type {object|undefined} */ let tab @@ -235,6 +237,11 @@ const viewDevices = document.getElementById('view-devices') const viewSettings = document.getElementById('view-settings') const btnSettings = document.getElementById('btn-settings') + const startsInSettings = window.location.hash === '#settings' && !globalSettings.hidePageAction + viewDevices.hidden = startsInSettings + viewSettings.hidden = !startsInSettings + btnSettings.classList.toggle('active', startsInSettings) + btnSettings.addEventListener('click', () => { const open = viewSettings.hidden viewDevices.hidden = open diff --git a/addon/js/internal/pages/settings/index.html b/addon/js/internal/pages/settings/index.html index 70e37ea..448daf2 100644 --- a/addon/js/internal/pages/settings/index.html +++ b/addon/js/internal/pages/settings/index.html @@ -28,6 +28,22 @@

Daemon as NM +
+
+

Hide Page Action

+
+

+ Keep the page action hidden, even when a site uses the WebHID API. The browser action + opens the device view by default. +

+
+
+ +
+

Data Plane

diff --git a/addon/js/internal/pages/settings/index.js b/addon/js/internal/pages/settings/index.js index 2a309d4..5b79780 100644 --- a/addon/js/internal/pages/settings/index.js +++ b/addon/js/internal/pages/settings/index.js @@ -18,6 +18,7 @@ for (const key of [ 'daemonAsNmHost', + 'hidePageAction', 'workerPolyfillEnabled', 'allowActivationlessRequestDevice' ]) { @@ -48,6 +49,7 @@ for (const key of [ 'daemonAsNmHost', + 'hidePageAction', 'workerPolyfillEnabled', 'allowActivationlessRequestDevice' ]) { diff --git a/addon/js/utils/settings.js b/addon/js/utils/settings.js index 7f1c951..9f203ea 100644 --- a/addon/js/utils/settings.js +++ b/addon/js/utils/settings.js @@ -12,6 +12,7 @@ dataPlane: typeof WebTransport !== 'undefined' ? 'wt' : 'ws', logLevel: 1, daemonAsNmHost: false, + hidePageAction: false, devicePickerMode: 'modal', workerPolyfillEnabled: false, workerSpawnMode: isChromium || isMv2 ? 'blob' : 'shadow', @@ -149,10 +150,11 @@ const SETTING_NAMES = Object.keys(GLOBAL_DEFAULTS) /** - * Settings that can be overridden per site, except the global-only - * `daemonAsNmHost`. + * Settings that can be overridden per site, except global-only settings. */ - const SITE_SETTING_NAMES = SETTING_NAMES.filter((n) => n !== 'daemonAsNmHost') + const SITE_SETTING_NAMES = SETTING_NAMES.filter( + (n) => n !== 'daemonAsNmHost' && n !== 'hidePageAction' + ) /** * Builds the storage key for a global setting. diff --git a/addon/manifest.chromium.json b/addon/manifest.chromium.json index 0ea943c..569e253 100644 --- a/addon/manifest.chromium.json +++ b/addon/manifest.chromium.json @@ -14,7 +14,7 @@ "service_worker": "background.js" }, "action": { - "default_popup": "js/internal/pages/popup/index.html", + "default_popup": "js/internal/pages/popup/index.html#settings", "default_icon": { "16": "icons/gamepad16.png", "32": "icons/gamepad32.png" diff --git a/addon/manifest.json b/addon/manifest.json index 74df064..f4b7275 100644 --- a/addon/manifest.json +++ b/addon/manifest.json @@ -44,13 +44,10 @@ }, "page_action": { "default_popup": "js/internal/pages/popup/index.html", - "default_icon": "icons/gamepad.svg", - "show_matches": [ - "http://*/*", - "https://*/*" - ] + "default_icon": "icons/gamepad.svg" }, "action": { + "default_popup": "js/internal/pages/popup/index.html#settings", "default_icon": "icons/icon.svg", "default_title": "WebHID" }, diff --git a/addon/manifest.v2.json b/addon/manifest.v2.json index 8fe43b1..daa7f02 100644 --- a/addon/manifest.v2.json +++ b/addon/manifest.v2.json @@ -42,13 +42,10 @@ }, "page_action": { "default_popup": "js/internal/pages/popup/index.html", - "default_icon": "icons/gamepad.svg", - "show_matches": [ - "http://*/*", - "https://*/*" - ] + "default_icon": "icons/gamepad.svg" }, "browser_action": { + "default_popup": "js/internal/pages/popup/index.html#settings", "default_icon": "icons/icon.svg", "default_title": "WebHID" }, diff --git a/scripts/build-addon.mjs b/scripts/build-addon.mjs index 26730d4..68f3f24 100755 --- a/scripts/build-addon.mjs +++ b/scripts/build-addon.mjs @@ -72,12 +72,15 @@ function collectScripts(manifest, add) { } function collectPages(manifest, add) { + const addPage = (page) => { + if (typeof page === 'string') add(page.split(/[?#]/, 1)[0]) + } for (const key of ['page_action', 'action', 'browser_action']) { - if (manifest[key]) add(manifest[key].default_popup) + if (manifest[key]) addPage(manifest[key].default_popup) } - add(manifest.options_ui && manifest.options_ui.page) - add(manifest.sidebar_action && manifest.sidebar_action.default_panel) - add(manifest.devtools_page) + addPage(manifest.options_ui && manifest.options_ui.page) + addPage(manifest.sidebar_action && manifest.sidebar_action.default_panel) + addPage(manifest.devtools_page) } function collectIcons(manifest, add) { diff --git a/tests/browser/page-action.spec.ts b/tests/browser/page-action.spec.ts new file mode 100644 index 0000000..3e9c969 --- /dev/null +++ b/tests/browser/page-action.spec.ts @@ -0,0 +1,282 @@ +import { test, expect } from '../helpers/browser.js' +import type { Page } from '@playwright/test' +import type { FirefoxBgPage } from '../helpers/harness.js' + +async function activePageActionShown(backgroundPage: FirefoxBgPage): Promise { + return backgroundPage.evaluate(async () => { + const tabs = await browser.tabs.query({ active: true }) + const tab = tabs.find((entry) => entry.url && /^https?:/.test(entry.url)) + return tab && tab.id != null ? browser.pageAction.isShown({ tabId: tab.id }) : false + }) +} +const HIDE_PAGE_ACTION_KEY = 'settings :: hidePageAction' +interface PendingPicker { + requestId: string | number + tabId: number + mode: 'pageAction' +} + +function isPendingPicker(value: unknown): value is PendingPicker { + if (value == null || typeof value !== 'object') return false + if (!('requestId' in value) || !('tabId' in value) || !('mode' in value)) return false + return ( + (typeof value.requestId === 'string' || typeof value.requestId === 'number') && + typeof value.tabId === 'number' && + value.mode === 'pageAction' + ) +} + +async function readPendingPicker(page: Page): Promise { + const pending: unknown = await page.evaluate(() => + browser.runtime.sendMessage({ action: 'getPendingPicker' }) + ) + if (!isPendingPicker(pending)) throw new Error('page-action picker is not pending') + return pending +} + +test.describe('extension action surfaces', () => { + test.beforeAll(async ({ backgroundPage }) => { + await backgroundPage.evaluate( + (key) => browser.storage.local.set({ [key]: false }), + HIDE_PAGE_ACTION_KEY + ) + }) + + test.afterAll(async ({ backgroundPage }) => { + await backgroundPage.evaluate( + (key) => browser.storage.local.set({ [key]: false }), + HIDE_PAGE_ACTION_KEY + ) + }) + + test('page action is hidden until a page uses WebHID', async ({ + backgroundPage, + sharedPage, + pageUrl + }) => { + await sharedPage.goto(pageUrl('/self-script'), { + waitUntil: 'domcontentloaded', + timeout: 15000 + }) + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(false) + + await sharedPage.evaluate(async () => { + try { + await navigator.hid.getDevices() + } catch {} + }) + + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(true) + }) + + test('global hide page action setting suppresses the icon and selects devices', async ({ + backgroundPage, + sharedPage, + pageUrl + }) => { + await backgroundPage.evaluate( + (settingKey) => browser.storage.local.set({ [settingKey]: true }), + HIDE_PAGE_ACTION_KEY + ) + try { + const settingsUrl = await backgroundPage.evaluate(() => + browser.runtime.getURL('js/internal/pages/settings/index.html') + ) + await sharedPage.goto(settingsUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }) + await expect(sharedPage.locator('#hidePageAction')).toBeChecked() + await sharedPage.goto(pageUrl('/self-script'), { + waitUntil: 'domcontentloaded', + timeout: 15000 + }) + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(false) + await sharedPage.evaluate(async () => { + try { + await navigator.hid.getDevices() + } catch {} + }) + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(false) + + const popupUrl = await backgroundPage.evaluate(() => + browser.runtime.getURL('js/internal/pages/popup/index.html') + ) + await sharedPage.goto(`${popupUrl}#settings`, { + waitUntil: 'domcontentloaded', + timeout: 15000 + }) + await expect(sharedPage.locator('#view-devices')).toBeVisible() + await expect(sharedPage.locator('#view-settings')).toBeHidden() + } finally { + await backgroundPage.evaluate( + (key) => browser.storage.local.set({ [key]: false }), + HIDE_PAGE_ACTION_KEY + ) + await sharedPage.goto('about:blank') + } + }) + + test('browser action popup opens on the settings view', async ({ + backgroundPage, + sharedPage + }) => { + const popupUrl = await backgroundPage.evaluate(() => + browser.runtime.getURL('js/internal/pages/popup/index.html') + ) + await sharedPage.goto(`${popupUrl}#settings`, { + waitUntil: 'domcontentloaded', + timeout: 15000 + }) + expect(sharedPage.url()).toContain('#settings') + await expect(sharedPage.locator('#view-settings')).toBeVisible() + await expect(sharedPage.locator('#view-devices')).toBeHidden() + }) + + test('page action popup opens on the devices view', async ({ backgroundPage, sharedPage }) => { + const popupUrl = await backgroundPage.evaluate(() => + browser.runtime.getURL('js/internal/pages/popup/index.html') + ) + await sharedPage.goto(popupUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }) + await expect(sharedPage.locator('#view-devices')).toBeVisible() + await expect(sharedPage.locator('#view-settings')).toBeHidden() + }) + test('hidden page action stays visible during its pending picker', async ({ + backgroundPage, + sharedPage, + page, + pageUrl + }) => { + const origin = new URL(pageUrl('/')).origin + const sitePickerKey = `settings :: ${origin} :: devicePickerMode` + const siteActivationKey = `settings :: ${origin} :: allowActivationlessRequestDevice` + await backgroundPage.evaluate( + ({ sitePickerKey, siteActivationKey }) => + browser.storage.local.set({ + 'settings :: hidePageAction': true, + [sitePickerKey]: 'pageAction', + [siteActivationKey]: true + }), + { sitePickerKey, siteActivationKey } + ) + try { + await sharedPage.goto(pageUrl('/self-script'), { + waitUntil: 'domcontentloaded', + timeout: 15000 + }) + await sharedPage.evaluate(() => { + const state = window as unknown as { pickerState?: string } + state.pickerState = 'pending' + navigator.hid + .requestDevice() + .then(() => { + state.pickerState = 'settled' + }) + .catch(() => { + state.pickerState = 'settled' + }) + }) + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(true) + + const pickerUrl = await backgroundPage.evaluate(() => + browser.runtime.getURL('js/internal/pages/picker/index.html') + ) + await page.goto(pickerUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }) + const pending = await readPendingPicker(page) + await page.evaluate( + (request) => + browser.runtime.sendMessage({ + action: 'pickerResult', + requestId: request.requestId, + tabId: request.tabId, + selected: false + }), + pending + ) + await expect + .poll(() => + sharedPage.evaluate(() => { + const value = 'pickerState' in window ? window.pickerState : undefined + return typeof value === 'string' ? value : undefined + }) + ) + .toBe('settled') + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(false) + } finally { + await backgroundPage.evaluate( + (keys) => browser.storage.local.remove(keys), + ['settings :: hidePageAction', sitePickerKey, siteActivationKey] + ) + } + }) + + test('enabling hide page action during a picker keeps it visible until finish', async ({ + backgroundPage, + sharedPage, + page, + pageUrl + }) => { + const origin = new URL(pageUrl('/')).origin + const hideKey = 'settings :: hidePageAction' + const sitePickerKey = `settings :: ${origin} :: devicePickerMode` + const siteActivationKey = `settings :: ${origin} :: allowActivationlessRequestDevice` + await backgroundPage.evaluate( + ({ hideKey, sitePickerKey, siteActivationKey }) => + browser.storage.local.set({ + [hideKey]: false, + [sitePickerKey]: 'pageAction', + [siteActivationKey]: true + }), + { hideKey, sitePickerKey, siteActivationKey } + ) + try { + await sharedPage.goto(pageUrl('/self-script'), { + waitUntil: 'domcontentloaded', + timeout: 15000 + }) + await sharedPage.evaluate(() => { + const state = window as unknown as { pickerState?: string } + state.pickerState = 'pending' + navigator.hid + .requestDevice() + .then(() => { + state.pickerState = 'settled' + }) + .catch(() => { + state.pickerState = 'settled' + }) + }) + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(true) + + await backgroundPage.evaluate((key) => browser.storage.local.set({ [key]: true }), hideKey) + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(true) + + const pickerUrl = await backgroundPage.evaluate(() => + browser.runtime.getURL('js/internal/pages/picker/index.html') + ) + await page.goto(pickerUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }) + const pending = await readPendingPicker(page) + await page.evaluate( + (request) => + browser.runtime.sendMessage({ + action: 'pickerResult', + requestId: request.requestId, + tabId: request.tabId, + selected: false + }), + pending + ) + await expect + .poll(() => + sharedPage.evaluate(() => { + const value = 'pickerState' in window ? window.pickerState : undefined + return typeof value === 'string' ? value : undefined + }) + ) + .toBe('settled') + await expect.poll(() => activePageActionShown(backgroundPage)).toBe(false) + } finally { + await backgroundPage.evaluate( + (keys) => browser.storage.local.remove(keys), + [hideKey, sitePickerKey, siteActivationKey] + ) + } + }) +}) diff --git a/tests/unit/content-ports.test.js b/tests/unit/content-ports.test.js new file mode 100644 index 0000000..dce6026 --- /dev/null +++ b/tests/unit/content-ports.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { runInNewContext } from 'node:vm' + +const source = readFileSync(new URL('../../addon/js/background/content-ports.js', import.meta.url), 'utf8') + +function loadContentPorts() { + let exported + const context = { + globalThis: null, + webhid: { + export(_name, value) { + exported = value + } + } + } + context.globalThis = context + runInNewContext(source, context) + return exported +} + +function makePort(tabId) { + const deliveries = [] + return { + name: 'webhid-data:7', + sender: tabId == null ? {} : { tab: { id: tabId } }, + onDisconnect: { addListener() {} }, + deliveries, + postMessage(message) { + deliveries.push(message) + } + } +} + +test('explicit targets only reach matching data Ports', () => { + const contentPorts = loadContentPorts() + const portA = makePort(11) + const nonmatchingPort = makePort(99) + const tablessPort = makePort(null) + contentPorts.registerContentPort(portA) + contentPorts.registerContentPort(nonmatchingPort) + contentPorts.registerContentPort(tablessPort) + + const message = { action: 'webhidDeviceEvent', event: { eventType: 'input_report' } } + const reached = contentPorts.postToContentPorts([11, 22], message, 'webhid-data:7') + + assert.deepEqual([...reached], [11]) + assert.deepEqual(portA.deliveries, [message]) + assert.deepEqual(nonmatchingPort.deliveries, []) + assert.deepEqual(tablessPort.deliveries, []) + assert.deepEqual([11, 22].filter((tabId) => !reached.has(tabId)), [22]) +})