Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run test:unit
- run: npm run build

chrome:
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# XDEBUG_PROFILE (cookie trigger)
Firefox addon for setting XDEBUG_PROFILE cookie

Now works in **Firefox 140+** and **Chrome / Chromium 121+** (one Manifest V3 codebase).
Now works in **Firefox 140+**, **Firefox for Android 142+** and **Chrome / Chromium 121+** (one Manifest V3 codebase).
On Android there are no keyboard shortcuts (the browser has no commands API); every browser API is
checked before use, so missing APIs only switch off the related feature.

The toolbar popup toggles Xdebug trigger cookies for the current site:

Expand Down Expand Up @@ -46,6 +48,7 @@ Package: `npm install && npm run build` → `web-ext-artifacts/*.zip` (works for
```sh
npm install
npm run lint # web-ext lint
npm run test:unit # no browser: API helpers, Firefox for Android-like environment
npm run test:chrome # Playwright, Chromium (CHROMIUM_PATH can point to a custom binary)
npm run test:firefox # Selenium + geckodriver, needs Firefox
# (FIREFOX_PATH / GECKODRIVER_PATH can point to custom binaries)
Expand Down
43 changes: 22 additions & 21 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ async function renderIcon(tab) {

log('updateIcon: tab', tab.id, tab.url, 'enabled:', badge || '-');
try {
await api.action.setIcon({ path: getIcons(enabled.length ? colors.on : colors.off), tabId: tab.id });
await api.action.setTitle({ title: title, tabId: tab.id });
await api.action.setBadgeText({ text: badge, tabId: tab.id });
await api.action.setBadgeBackgroundColor({ color: '#c0392b', tabId: tab.id });
await callApi('action.setIcon', [{ path: getIcons(enabled.length ? colors.on : colors.off), tabId: tab.id }]);
await callApi('action.setTitle', [{ title: title, tabId: tab.id }]);
await callApi('action.setBadgeText', [{ text: badge, tabId: tab.id }]);
await callApi('action.setBadgeBackgroundColor', [{ color: '#c0392b', tabId: tab.id }]);
} catch (e) {
// The tab may be closed between query and update
logError('updateIcon failed', e);
Expand All @@ -69,7 +69,7 @@ async function renderIcon(tab) {

// Service worker has no persistent state, so active tabs are always queried
async function updateActiveTabs() {
let tabs = await api.tabs.query({ active: true });
let tabs = await callApi('tabs.query', [{ active: true }], []);
for (let tab of tabs) {
await updateIcon(tab);
}
Expand All @@ -78,15 +78,15 @@ async function updateActiveTabs() {
// Removes cookies with the old name after it has been changed in options,
// otherwise they stay in the browser and keep triggering Xdebug.
async function removeCookiesByName(name, value) {
let stores = await api.cookies.getAllCookieStores();
let stores = await callApi('cookies.getAllCookieStores', [], []);
for (let store of stores) {
let query = { name: name, storeId: store.id };
if (IS_FIREFOX) {
query.firstPartyDomain = null;
}
let cookies = [];
try {
cookies = await api.cookies.getAll(query);
cookies = await requireApi('cookies.getAll', [query]);
} catch (e) {
logError('removeCookiesByName: getAll failed for store', store.id, e);
continue;
Expand All @@ -106,7 +106,7 @@ async function removeCookiesByName(name, value) {

async function onSettingsChanged(changes, area) {
log('settings changed in', area, changes);
if (area === 'sync') {
if (area === SETTINGS_AREA) {
let triggers = await getTriggerSettings();
for (let trigger of TRIGGERS) {
let nameChange = changes[trigger.key];
Expand Down Expand Up @@ -135,7 +135,7 @@ async function onCookieChanged(changeInfo) {
}
log('cookie changed:', changeInfo.cookie.name, changeInfo.cookie.domain, 'removed:', changeInfo.removed, 'cause:', changeInfo.cause);
let domain = changeInfo.cookie.domain.replace(/^\./, '');
let tabs = await api.tabs.query({});
let tabs = await callApi('tabs.query', [{}], []);
for (let tab of tabs) {
let host = getHost(tab.url);
if (host === domain || host.endsWith('.' + domain)) {
Expand All @@ -154,7 +154,7 @@ function onTabUpdated(tabId, changeInfo, tab) {

async function onTabActivated(activeInfo) {
try {
updateIcon(await api.tabs.get(activeInfo.tabId));
updateIcon(await requireApi('tabs.get', [activeInfo.tabId]));
} catch (e) {
logError('onTabActivated: tabs.get failed', e);
}
Expand All @@ -178,20 +178,21 @@ async function onInstalled(details) {
log('installed:', details.reason);
// On first install the options page asks to choose the IDE
if (details.reason === 'install' || !await hasHostPermission()) {
api.runtime.openOptionsPage();
callApi('runtime.openOptionsPage', []);
}
await updateActiveTabs();
}

// Listeners must be registered synchronously at top level (service worker requirement)
api.tabs.onUpdated.addListener(onTabUpdated);
api.tabs.onActivated.addListener(onTabActivated);
api.windows.onFocusChanged.addListener(updateActiveTabs);
api.cookies.onChanged.addListener(onCookieChanged);
api.storage.onChanged.addListener(onSettingsChanged);
api.commands.onCommand.addListener(onCommand);
api.runtime.onInstalled.addListener(onInstalled);
api.permissions.onAdded.addListener(updateActiveTabs);
api.permissions.onRemoved.addListener(updateActiveTabs);
// Listeners must be registered synchronously at top level (service worker requirement).
// Missing events are skipped: e.g. Firefox for Android has no windows and commands (keyboard shortcuts) APIs.
addApiListener('tabs.onUpdated', onTabUpdated);
addApiListener('tabs.onActivated', onTabActivated);
addApiListener('cookies.onChanged', onCookieChanged);
addApiListener('storage.onChanged', onSettingsChanged);
addApiListener('runtime.onInstalled', onInstalled);
addApiListener('windows.onFocusChanged', updateActiveTabs);
addApiListener('commands.onCommand', onCommand);
addApiListener('permissions.onAdded', updateActiveTabs);
addApiListener('permissions.onRemoved', updateActiveTabs);

updateActiveTabs();
102 changes: 83 additions & 19 deletions common.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,62 @@
// Firefox exposes `browser`, Chrome exposes `chrome` (both return promises in MV3).
const api = globalThis.browser || globalThis.chrome;
const LOG_PREFIX = '[XDEBUG_PROFILE]';

// --- Safe access to WebExtension APIs ---
// Browsers support different sets of APIs (e.g. Firefox for Android has no windows and commands),
// so every API method and event is checked before use.

// Returns the API object or function by path ('tabs.getCurrent'), undefined if the browser has none
function getApi(path) {
let node = api;
for (let part of path.split('.')) {
if (node === undefined || node === null) {
return undefined;
}
node = node[part];
}
return node;
}

function hasApi(path) {
return getApi(path) !== undefined;
}

// Calls an API method if the browser has it, otherwise returns fallback.
// The result is returned as is (usually a promise), so callers can await it in both cases.
function callApi(path, args, fallback) {
let method = getApi(path);
if (typeof method !== 'function') {
log('API is not available:', path);
return fallback;
}
return method.apply(getApi(path.split('.').slice(0, -1).join('.')), args || []);
}

// The same for APIs the action can't work without: throws an error that is shown to the user
function requireApi(path, args) {
let method = getApi(path);
if (typeof method !== 'function') {
throw new Error('API is not available in this browser: ' + path);
}
return method.apply(getApi(path.split('.').slice(0, -1).join('.')), args || []);
}

// Subscribes to an API event if the browser has it, returns false otherwise
function addApiListener(path, listener) {
let event = getApi(path);
if (!event || typeof event.addListener !== 'function') {
log('API event is not available:', path);
return false;
}
event.addListener(listener);
return true;
}

// firstPartyDomain exists only in Firefox, Chrome rejects unknown properties
const IS_FIREFOX = typeof api.runtime.getBrowserInfo === 'function';
const IS_FIREFOX = hasApi('runtime.getBrowserInfo');
// Settings are kept in storage.sync, or in storage.local if the browser has no sync storage
const SETTINGS_AREA = hasApi('storage.sync') ? 'sync' : 'local';

// Supported Xdebug triggers. Storage keys of PROFILE are the same as in old versions.
const TRIGGERS = [
Expand All @@ -30,7 +84,7 @@ const IDE_CHOSEN_KEY = 'ide_chosen';

// Localized string from _locales/*/messages.json, falls back to the key itself
function t(key, ...substitutions) {
let message = api.i18n.getMessage(key, substitutions.map(String));
let message = callApi('i18n.getMessage', [key, substitutions.map(String)], '');
if (!message) {
console.warn(LOG_PREFIX, 'i18n: no message for', key);
return key;
Expand Down Expand Up @@ -84,7 +138,8 @@ function isSupportedUrl(url) {

async function hasHostPermission() {
try {
return await api.permissions.contains(SITE_ORIGINS);
// Without the permissions API host permissions are granted at install time
return await callApi('permissions.contains', [SITE_ORIGINS], true);
} catch (e) {
logError('permissions.contains failed', e);
return false;
Expand All @@ -93,7 +148,7 @@ async function hasHostPermission() {

// Must be called from a user action handler (click)
async function requestHostPermission() {
let granted = await api.permissions.request(SITE_ORIGINS);
let granted = await requireApi('permissions.request', [SITE_ORIGINS]);
log('host permission granted:', granted);
return granted;
}
Expand All @@ -105,7 +160,7 @@ async function getTriggerSettings(host) {
for (let trigger of TRIGGERS) {
keys.push(trigger.key, trigger.key + '_value');
}
let config = await api.storage.sync.get(keys);
let config = await callApi('storage.' + SETTINGS_AREA + '.get', [keys], {});
let siteIdeKey = host ? await getSiteIdeKey(host) : '';

return TRIGGERS.map(trigger => Object.assign({}, trigger, {
Expand All @@ -120,22 +175,22 @@ async function getTriggerSettings(host) {
// Useful when some projects work through DBGp proxy with their own key.
async function getSiteIdeKey(host) {
let key = 'idekey:' + host;
let data = await api.storage.local.get(key);
let data = await callApi('storage.local.get', [key], {});
return data[key] || '';
}

async function setSiteIdeKey(host, ideKey) {
let key = 'idekey:' + host;
if (ideKey) {
await api.storage.local.set({ [key]: ideKey });
await callApi('storage.local.set', [{ [key]: ideKey }]);
} else {
await api.storage.local.remove(key);
await callApi('storage.local.remove', [key]);
}
log('IDE key for', host, '=', ideKey || '(global)');
}

async function getColors() {
let config = await api.storage.sync.get(['cookie_XDEBUG_PROFILE_on_color', 'cookie_XDEBUG_PROFILE_off_color']);
let config = await callApi('storage.' + SETTINGS_AREA + '.get', [['cookie_XDEBUG_PROFILE_on_color', 'cookie_XDEBUG_PROFILE_off_color']], {});

return {
on: config.cookie_XDEBUG_PROFILE_on_color || DEFAULT_ON_COLOR,
Expand All @@ -149,7 +204,7 @@ async function getStoreId(tab) {
if (tab.cookieStoreId) {
return tab.cookieStoreId;
}
let stores = await api.cookies.getAllCookieStores();
let stores = await callApi('cookies.getAllCookieStores', [], []);
for (let store of stores) {
if (store.tabIds && store.tabIds.includes(tab.id)) {
return store.id;
Expand Down Expand Up @@ -184,7 +239,7 @@ function getDomainCandidates(host) {
// Scope is remembered per host: '' - host-only cookie, 'example.com' - cookie for .example.com and subdomains
async function getScope(host) {
let key = 'scope:' + host;
let data = await api.storage.local.get(key);
let data = await callApi('storage.local.get', [key], {});
let scope = data[key] || '';
if (scope && !getDomainCandidates(host).includes(scope)) {
return '';
Expand All @@ -195,9 +250,9 @@ async function getScope(host) {
async function setScope(host, scope) {
let key = 'scope:' + host;
if (scope) {
await api.storage.local.set({ [key]: scope });
await callApi('storage.local.set', [{ [key]: scope }]);
} else {
await api.storage.local.remove(key);
await callApi('storage.local.remove', [key]);
}
log('scope for', host, '=', scope || '(host only)');
}
Expand All @@ -209,11 +264,11 @@ async function getCookie(tab, trigger) {

let cookie = null;
try {
cookie = await api.cookies.get({
cookie = await requireApi('cookies.get', [{
url: tab.url,
name: trigger.name,
storeId: await getStoreId(tab)
});
}]);
} catch (e) {
logError('getCookie failed for', tab.url, trigger.name, e);
return null;
Expand Down Expand Up @@ -252,7 +307,7 @@ async function removeCookie(cookie) {
if (IS_FIREFOX && cookie.firstPartyDomain !== undefined) {
details.firstPartyDomain = cookie.firstPartyDomain;
}
await api.cookies.remove(details);
await requireApi('cookies.remove', [details]);
log('removed', cookie.name, 'for', details.url, 'store', cookie.storeId);
}

Expand All @@ -266,7 +321,7 @@ async function disableTrigger(tab, trigger) {
if (IS_FIREFOX) {
query.firstPartyDomain = null;
}
let cookies = await api.cookies.getAll(query);
let cookies = await requireApi('cookies.getAll', [query]);
for (let cookie of cookies) {
await removeCookie(cookie);
}
Expand All @@ -285,7 +340,7 @@ async function enableTrigger(tab, trigger) {
if (scope) {
details.domain = scope;
}
await api.cookies.set(details);
await requireApi('cookies.set', [details]);
log('set', trigger.name + '=' + trigger.value, 'for', scope ? '.' + scope : host, 'store', details.storeId);
}

Expand Down Expand Up @@ -319,6 +374,15 @@ async function reapplyTriggers(tab, triggerIds) {
}

async function getActiveTab() {
let tabs = await api.tabs.query({ active: true, currentWindow: true });
let tabs = await callApi('tabs.query', [{ active: true, currentWindow: true }], []);
// Firefox for Android shows the popup as a page in its own tab: then the active tab is the
// popup itself, and the site is the most recently used other tab
let ownTab = typeof document !== 'undefined' ? await callApi('tabs.getCurrent', [], undefined) : undefined;
if (ownTab && tabs[0] && tabs[0].id === ownTab.id) {
let others = (await callApi('tabs.query', [{}], [])).filter(tab => tab.id !== ownTab.id);
others.sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0));
log('getActiveTab: popup is shown in a tab, using the last used tab', others[0] && others[0].url);
return others[0];
}
return tabs[0];
}
10 changes: 7 additions & 3 deletions options.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ async function saveOptions(e) {
}
console.debug(OPTIONS_LOG_PREFIX, 'saving', options);

let saved = api.storage.sync.set(options);
saved.then(showSuccess, showError);
try {
await requireApi('storage.' + SETTINGS_AREA + '.set', [options]);
showSuccess();
} catch (err) {
showError(err);
}
}

async function restoreOptions() {
Expand Down Expand Up @@ -80,7 +84,7 @@ async function restoreOptions() {
container.appendChild(rows);
}

let config = await api.storage.sync.get(IDE_CHOSEN_KEY);
let config = await callApi('storage.' + SETTINGS_AREA + '.get', [IDE_CHOSEN_KEY], {});
document.querySelector(".welcome-row").hidden = !!config[IDE_CHOSEN_KEY];

document.querySelector(".cookie-XDEBUG_PROFILE-on-color[value="+ colors.on +"]").checked = true;
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"scripts": {
"lint": "web-ext lint",
"build": "web-ext build --overwrite-dest",
"test": "npm run test:chrome",
"test": "npm run test:unit && npm run test:chrome",
"test:chrome": "node --test --test-concurrency=1 tests/chrome.test.js",
"test:firefox": "npm run build && node --test --test-concurrency=1 tests/firefox.test.js"
"test:firefox": "npm run build && node --test --test-concurrency=1 tests/firefox.test.js",
"test:unit": "node --test tests/android-env.test.js"
},
"devDependencies": {
"playwright": "1.56.1",
Expand Down
4 changes: 2 additions & 2 deletions popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function showError(message) {
async function render() {
// popup.html?tabId=N opens the popup for a given tab (for debugging in a regular tab)
let debugTabId = new URLSearchParams(location.search).get('tabId');
popupTab = debugTabId ? await api.tabs.get(Number(debugTabId)) : await getActiveTab();
popupTab = debugTabId ? await callApi('tabs.get', [Number(debugTabId)], undefined) : await getActiveTab();
let host = popupTab ? getHost(popupTab.url) : '';
log('popup: render for', popupTab && popupTab.url);

Expand Down Expand Up @@ -173,7 +173,7 @@ document.querySelector('.site-ide-custom-row').addEventListener('submit', onSite
document.querySelector('.grant').addEventListener('click', onGrantClick);
function openOptions(e) {
e.preventDefault();
api.runtime.openOptionsPage();
callApi('runtime.openOptionsPage', []);
window.close();
}

Expand Down
Loading
Loading