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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ Interface languages: English, Russian, Kazakh (follows the browser language).

## Keyboard shortcuts

- `Alt+Shift+X` - open popup
- `Alt+Shift+P` - toggle profiler cookie
- `Alt+Shift+K` - open popup
- `Alt+Shift+F` - toggle profiler cookie
- `Alt+Shift+D` - toggle debugger cookie

(`Alt+Shift+P` and `Alt+Shift+X` are reserved by Chrome for tab groups.)
- toggle trace cookie - no default shortcut

Firefox: `about:addons` → gear → "Manage Extension Shortcuts".
Expand Down
13 changes: 13 additions & 0 deletions _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,18 @@
},
"optSaveError": {
"message": "There were some errors during save, please try again."
},
"optInvalidName": {
"message": "Invalid cookie name: \"$1\"."
},
"optDuplicateName": {
"message": "The same cookie name is used twice: \"$1\"."
},
"optInvalidValue": {
"message": "Invalid cookie value: \"$1\" (spaces, quotes, commas, semicolons and backslashes are not allowed)."
},
"langCode": {
"message": "en",
"description": "Language code of this translation for the lang attribute"
}
}
13 changes: 13 additions & 0 deletions _locales/kk/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,18 @@
},
"optSaveError": {
"message": "Сақтау кезінде қате орын алды, қайталап көріңіз."
},
"optInvalidName": {
"message": "Cookie атауы жарамсыз: «$1»."
},
"optDuplicateName": {
"message": "Бір cookie атауы екі рет көрсетілген: «$1»."
},
"optInvalidValue": {
"message": "Cookie мәні жарамсыз: «$1» (бос орын, тырнақша, үтір, нүктелі үтір және кері қиғаш сызық қолдануға болмайды)."
},
"langCode": {
"message": "kk",
"description": "Language code of this translation for the lang attribute"
}
}
13 changes: 13 additions & 0 deletions _locales/ru/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,18 @@
},
"optSaveError": {
"message": "При сохранении произошла ошибка, попробуйте ещё раз."
},
"optInvalidName": {
"message": "Недопустимое имя cookie: «$1»."
},
"optDuplicateName": {
"message": "Одно и то же имя cookie указано дважды: «$1»."
},
"optInvalidValue": {
"message": "Недопустимое значение cookie: «$1» (нельзя использовать пробелы, кавычки, запятые, точки с запятой и обратную косую черту)."
},
"langCode": {
"message": "ru",
"description": "Language code of this translation for the lang attribute"
}
}
1 change: 1 addition & 0 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,6 @@ 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);

updateActiveTabs();
15 changes: 14 additions & 1 deletion common.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,25 @@ function t(key, ...substitutions) {

// Translates elements with data-i18n (text), data-i18n-placeholder and data-i18n-title attributes
function localizePage(root) {
root = root || document;
if (!root) {
root = document;
// Language of the translation actually used (stored in messages.json)
document.documentElement.lang = t('langCode');
}
root.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
root.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); });
root.querySelectorAll('[data-i18n-title]').forEach(el => { el.title = t(el.dataset.i18nTitle); });
}

// RFC 6265: cookie-name is a token, cookie-value has no spaces, quotes, commas, semicolons, backslashes
function isValidCookieName(name) {
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
}

function isValidCookieValue(value) {
return /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/.test(value);
}

function getIdePreset(key) {
return IDE_PRESETS.find(preset => preset.key === key) || null;
}
Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Alt+Shift+X"
"default": "Alt+Shift+K"
},
"description": "Open popup"
},
"toggle-profile": {
"suggested_key": {
"default": "Alt+Shift+P"
"default": "Alt+Shift+F"
},
"description": "Toggle profiler cookie (XDEBUG_PROFILE)"
},
Expand Down
8 changes: 7 additions & 1 deletion options.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
:root {
/* Default colors and form controls follow the browser light/dark theme */
color-scheme: light dark;
--gutter-size: 10px;
--input-height: 2rem;
--label-width: 26rem;
Expand Down Expand Up @@ -51,7 +53,7 @@ input[type=radio],

.message-container {
position: relative;
height: 3.1rem;
min-height: 3.1rem;
}

.message {
Expand Down Expand Up @@ -127,3 +129,7 @@ input[type=radio],
font-size: 0.85rem;
opacity: 0.75;
}

.invalid {
outline: 2px solid #c0392b;
}
38 changes: 33 additions & 5 deletions options.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,41 @@ async function saveOptions(e) {
cookie_XDEBUG_PROFILE_on_color: onColor ? onColor.value : DEFAULT_ON_COLOR,
cookie_XDEBUG_PROFILE_off_color: offColor ? offColor.value : DEFAULT_OFF_COLOR
};
let errors = [];
let names = {};
document.querySelectorAll(".invalid").forEach(el => el.classList.remove("invalid"));
for (let trigger of TRIGGERS) {
options[trigger.key] = document.querySelector(".trigger-name[data-key=" + trigger.key + "]").value.trim();
let nameInput = document.querySelector(".trigger-name[data-key=" + trigger.key + "]");
options[trigger.key] = nameInput.value.trim();
let effectiveName = options[trigger.key] || trigger.defaultName;
if (!isValidCookieName(effectiveName)) {
errors.push(t('optInvalidName', effectiveName));
nameInput.classList.add("invalid");
} else if (names[effectiveName]) {
errors.push(t('optDuplicateName', effectiveName));
nameInput.classList.add("invalid");
}
names[effectiveName] = true;
let value = document.querySelector(".trigger-value[data-key=" + trigger.key + "]").value.trim();
let ide = document.querySelector(".trigger-ide[data-key=" + trigger.key + "]");
if (trigger.ide && ide.value) {
// Preset selected, custom input is ignored
value = ide.value;
}
if (!isValidCookieValue(value)) {
errors.push(t('optInvalidValue', value));
document.querySelector(".trigger-value[data-key=" + trigger.key + "]").classList.add("invalid");
}
options[trigger.key + '_value'] = value;
if (trigger.ide) {
options[IDE_CHOSEN_KEY] = true;
}
}
if (errors.length) {
console.debug(OPTIONS_LOG_PREFIX, 'not saved, invalid values:', errors);
showError(errors.join(' '));
return;
}
console.debug(OPTIONS_LOG_PREFIX, 'saving', options);

let saved = api.storage.sync.set(options);
Expand Down Expand Up @@ -117,10 +139,16 @@ function showSuccess() {
}, 2000);
}

function showError(error) {
console.error(OPTIONS_LOG_PREFIX, 'save failed', error);
let messageClass = document.querySelector(".saved-error").classList;
messageClass.add(visibilityClass);
// message - validation text; without it (storage error) the generic text is shown
function showError(message) {
let element = document.querySelector(".saved-error");
if (typeof message === 'string') {
element.textContent = message;
} else {
console.error(OPTIONS_LOG_PREFIX, 'save failed', message);
element.textContent = t('optSaveError');
}
element.classList.add(visibilityClass);
}

function hideMessages() {
Expand Down
1 change: 1 addition & 0 deletions popup.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
:root {
color-scheme: light dark;
--text: #15141a;
--muted: #6b6a70;
--bg: #ffffff;
Expand Down
4 changes: 4 additions & 0 deletions popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ async function onSiteIdeChange(e) {
async function onSiteIdeCustomSubmit(e) {
e.preventDefault();
let value = document.querySelector('.site-ide-custom').value.trim();
if (!isValidCookieValue(value)) {
showError(t('optInvalidValue', value));
return;
}
await applySiteIdeKey(value);
}

Expand Down
4 changes: 2 additions & 2 deletions store/listing-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ Features:
• IDE presets: VS Code (vsc), PhpStorm, NetBeans, Eclipse, MacGDBp or a custom IDE key
• Own IDE key for a site (e.g. projects behind a DBGp proxy)
• Custom cookie names and values (xdebug.trigger_value)
• Keyboard shortcuts: Alt+Shift+P — profiler, Alt+Shift+D — debugger, Alt+Shift+X — popup
• Works with Firefox containers and private windows
• Keyboard shortcuts: Alt+Shift+F — profiler, Alt+Shift+D — debugger, Alt+Shift+K — popup
• Works with Firefox containers, private windows and Chrome incognito (after you allow the extension there in the browser add-on settings)
• English, Russian and Kazakh interface

Server setup (Xdebug 3):
Expand Down
4 changes: 2 additions & 2 deletions store/listing-ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ Xdebug Cookies: Debug, Profile, Trace
• Готовые IDE: VS Code (vsc), PhpStorm, NetBeans, Eclipse, MacGDBp или свой IDE key
• Свой IDE key для отдельного сайта (например, для проектов за DBGp-прокси)
• Свои имена и значения cookie (xdebug.trigger_value)
• Горячие клавиши: Alt+Shift+P — профайлер, Alt+Shift+D — отладчик, Alt+Shift+X — окно
• Работает с контейнерами Firefox и приватными окнами
• Горячие клавиши: Alt+Shift+F — профайлер, Alt+Shift+D — отладчик, Alt+Shift+K — окно
• Работает с контейнерами Firefox, приватными окнами и режимом инкогнито Chrome (после того как вы разрешите там расширение в настройках дополнений браузера)
• Интерфейс на английском, русском и казахском

Настройка сервера (Xdebug 3):
Expand Down
100 changes: 100 additions & 0 deletions tests/chrome.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ describe('Chromium', () => {
assert.ok(urls.some(u => u.includes('options')), 'pages: ' + urls.join(', '));
});

it('keyboard shortcuts are assigned (not reserved by Chrome)', async () => {
const commands = await worker.evaluate(() => api.commands.getAll());
const shortcuts = Object.fromEntries(commands.map(c => [c.name, c.shortcut]));
console.log('[test] shortcuts', shortcuts);
assert.equal(shortcuts._execute_action, 'Alt+Shift+K');
assert.equal(shortcuts['toggle-profile'], 'Alt+Shift+F');
assert.equal(shortcuts['toggle-debug'], 'Alt+Shift+D');
});

it('has host permission and shows three triggers', async () => {
assert.ok(await popup.isHidden('.no-permission'));
const rows = await popup.$$eval('.trigger', els => els.map(e => e.textContent.replace(/\s+/g, ' ').trim()));
Expand Down Expand Up @@ -189,6 +198,46 @@ describe('Chromium', () => {
await options.close();
});

it('options: invalid and duplicate cookie names are not saved', async () => {
const options = await context.newPage();
await options.goto(`chrome-extension://${extensionId}/options.html`);
await options.waitForSelector('.trigger-name');
const stored = () => worker.evaluate(() => api.storage.sync.get(null));
const before = await stored();

await options.fill('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'XDEBUG TRACE');
await options.click('button[type=submit]');
await eventually(async () => assert.match(await options.textContent('.saved-error'), /XDEBUG TRACE/));
assert.ok(await options.isVisible('.saved-error'));
assert.equal(await options.getAttribute('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'class'), 'trigger-name invalid');

await options.fill('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'XDEBUG_PROFILE');
await options.click('button[type=submit]');
await eventually(async () => assert.match(await options.textContent('.saved-error'), /twice: "XDEBUG_PROFILE"/));

await options.fill('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'XDEBUG_TRACE');
await options.fill('.trigger-value[data-key=cookie_XDEBUG_PROFILE]', 'a;b');
await options.click('button[type=submit]');
await eventually(async () => assert.match(await options.textContent('.saved-error'), /"a;b"/));
assert.deepEqual(await stored(), before, 'invalid options must not be saved');

await options.fill('.trigger-value[data-key=cookie_XDEBUG_PROFILE]', 'Y');
await options.click('button[type=submit]');
await eventually(async () => assert.ok(await options.isVisible('.saved-successfully')));
assert.equal(await options.$$eval('.invalid', els => els.length), 0);
await options.close();
});

it('popup: invalid custom IDE key is rejected', async () => {
await openPopup();
await popup.click('.trigger-ide');
await popup.selectOption('.site-ide', '__custom__');
await popup.fill('.site-ide-custom', 'my key');
await popup.click('.site-ide-custom-row button');
await eventually(async () => assert.match(await popup.textContent('.error'), /"my key"/));
assert.equal(await worker.evaluate(() => getSiteIdeKey('app.example.co.uk')), '');
});

it('shows unsupported message on browser pages', async () => {
// Without the "tabs" permission non-http tabs can't be found by URL, so the site tab is reused
await site.goto('about:blank');
Expand Down Expand Up @@ -235,3 +284,54 @@ for (const [lang, debugLabel, settingsLabel] of [['ru', 'Отладка', 'На
});
});
}

describe('Chromium incognito', () => {
let server, context, extensionId;

before(async () => {
server = await startServer();
({ context, extensionId } = await launch());
});

after(async () => {
await context.close();
server.close();
});

it('works in incognito after the user allows it (own cookie store)', async () => {
// Same switches as on chrome://extensions: developer mode (unpacked addon) and "Allow in Incognito"
const page = await context.newPage();
await page.goto('chrome://extensions/');
const info = id => page.evaluate(i => new Promise(r => chrome.developerPrivate.getExtensionInfo(i, r)), id);
assert.equal((await info(extensionId)).incognitoAccess.isActive, false, 'not allowed in incognito by default');
await page.evaluate(() => new Promise(r => chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true }, r)));
await page.evaluate(id => new Promise(r => chrome.developerPrivate.updateExtensionConfiguration(
{ extensionId: id, incognitoAccess: true }, r)), extensionId);
await eventually(async () => assert.equal((await info(extensionId)).state, 'ENABLED'), 10000);

// The addon is reloaded, its functions are called from an extension page
const extPage = await context.newPage();
await eventually(() => extPage.goto(`chrome-extension://${extensionId}/options.html`), 10000);
const result = await extPage.evaluate(async site => {
const win = await api.windows.create({ incognito: true, url: site });
let tab;
for (let i = 0; i < 50; i++) {
tab = (await api.tabs.query({ windowId: win.id }))[0];
if (tab.status === 'complete' && tab.url) {
break;
}
await new Promise(r => setTimeout(r, 200));
}
const count = async storeId => (await api.cookies.getAll({ name: 'XDEBUG_PROFILE', storeId: storeId })).length;
const out = { incognito: tab.incognito, store: await getStoreId(tab) };
out.enabled = await toggleTrigger(tab, 'profile');
out.inIncognito = await count('1');
out.inNormal = await count('0');
out.disabled = await toggleTrigger(tab, 'profile');
out.afterOff = await count('1');
await api.windows.remove(win.id);
return out;
}, SITE);
assert.deepEqual(result, { incognito: true, store: '1', enabled: true, inIncognito: 1, inNormal: 0, disabled: false, afterOff: 0 });
});
});
Loading
Loading