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
11 changes: 10 additions & 1 deletion scripts/firefox-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,10 @@ const scenarios = [
await activateHeaders();
await click(selectors.profileNameEditButton);
}),
scenario('profiles', 'profile-actions-menu', async ({ activateHeaders, click }) => {
scenario('profiles', 'profile-actions-menu', async ({ activateHeaders, click, hideMenuItem }) => {
await activateHeaders();
await click(selectors.profileActionsButton);
await hideMenuItem('Share headers by URL');
}),
scenario('url-filters', 'empty-state', async ({ activateUrlFilters }) => {
await activateUrlFilters();
Expand Down Expand Up @@ -312,6 +313,14 @@ function createBrowserHelpers(driver, popupUrl) {
click,
clickMenuItem,
fill,
hideMenuItem: async (text) => {
const xpath = `//*[@role="menuitem" and contains(normalize-space(.), "${text}")]`;
const item = (await driver.findElements(By.xpath(xpath)))[0];
if (!item) {
throw new Error(`Missing menu item "${text}"`);
}
await driver.executeScript('arguments[0].style.display = "none"', item);
},
removeAllCookies: async () => {
await click(selectors.removeAllCookiesButton);
},
Expand Down
13 changes: 12 additions & 1 deletion src/background.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import browser from 'webextension-polyfill';

import { importSharedHeadersFromTab } from '#features/share-headers-by-url/background';

import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants';
import { browserAction } from './shared/utils/browserAPI';
import { logger, LogLevel } from './shared/utils/logger';
Expand Down Expand Up @@ -170,6 +172,11 @@ browser.runtime.onInstalled.addListener(async details => {
browser.tabs.onActivated.addListener(async activeInfo => {
logger.debug('Tab activated:', activeInfo);

const tab = await browser.tabs.get(activeInfo.tabId);
if (await importSharedHeadersFromTab(activeInfo.tabId, tab.url)) {
return;
}

const result = await browser.storage.local.get([
BrowserStorageKey.Profiles,
BrowserStorageKey.SelectedProfile,
Expand All @@ -181,7 +188,6 @@ browser.tabs.onActivated.addListener(async activeInfo => {
if (Object.keys(result).length) {
logger.info('📱 Tab activated, updating headers');
try {
const tab = await browser.tabs.get(activeInfo.tabId);
await setBrowserHeaders(result, tab.url);
} catch (error) {
logger.error('Failed to set browser headers on tab activation:', error);
Expand All @@ -192,6 +198,11 @@ browser.tabs.onActivated.addListener(async activeInfo => {
});

browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
const updatedUrl = changeInfo.url ?? (changeInfo.status === 'complete' ? tab.url : undefined);
if (updatedUrl && (await importSharedHeadersFromTab(tabId, updatedUrl))) {
return;
}

if (changeInfo.status !== 'complete') return;

const activeTabs = await browser.tabs.query({ active: true, currentWindow: true });
Expand Down
1 change: 1 addition & 0 deletions src/entities/modal/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './import-profile-modal';
export * from './import-profile-from-extension-modal';
export * from './export-profile-modal';
export * from './share-headers-by-url-modal';
8 changes: 8 additions & 0 deletions src/entities/modal/model/share-headers-by-url-modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createEvent, createStore } from 'effector';

export const shareHeadersByUrlModalOpened = createEvent();
export const shareHeadersByUrlModalClosed = createEvent();

export const $isShareHeadersByUrlModalOpen = createStore(false)
.on(shareHeadersByUrlModalOpened, () => true)
.on(shareHeadersByUrlModalClosed, () => false);
93 changes: 93 additions & 0 deletions src/features/share-headers-by-url/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Profile, RequestHeader } from '#entities/request-profile/types';

import {
createProfileFromSharedHeaders,
createSharedHeadersUrl,
normalizeSharedHeadersOrigin,
type ParsedSharedHeadersUrl,
parseSharedHeadersUrl,
SHARE_HEADERS_FRAGMENT_KEY,
} from '../utils';

function encodePayload(payload: unknown) {
const bytes = new TextEncoder().encode(JSON.stringify(payload));
let binary = '';
bytes.forEach(byte => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
}

describe('share headers by URL', () => {
it.each([
['example.com', 'https://example.com'],
['https://example.com', 'https://example.com'],
['http://localhost:3000', 'http://localhost:3000'],
])('normalizes %s to %s', (input, expected) => {
expect(normalizeSharedHeadersOrigin(input)).toBe(expected);
});

it.each(['', 'ftp://example.com', 'https://user:[email protected]', 'https://example.com/path'])(
'rejects invalid domain input %s',
input => {
expect(() => normalizeSharedHeadersOrigin(input)).toThrow();
},
);

it('serializes only active valid headers and parses them back', () => {
const headers: RequestHeader[] = [
{ id: 1, name: 'X-Active', value: 'café', disabled: false },
{ id: 2, name: 'X-Disabled', value: 'ignored', disabled: true },
{ id: 3, name: '', value: '', disabled: false },
];

const sharedUrl = createSharedHeadersUrl('example.com', headers);
const parsed = parseSharedHeadersUrl(sharedUrl);

expect(sharedUrl).toMatch(/^https:\/\/example\.com\/#__cloudhood=/u);
expect(parsed).toEqual({
headers: [{ name: 'X-Active', value: 'café' }],
hostname: 'example.com',
origin: 'https://example.com',
sanitizedUrl: 'https://example.com/',
});
});

it('rejects malformed, unsupported, and invalid payloads', () => {
const createUrl = (payload: unknown) =>
`https://example.com/#${SHARE_HEADERS_FRAGMENT_KEY}=${encodePayload(payload)}`;

expect(parseSharedHeadersUrl('https://example.com/')).toBeNull();
expect(parseSharedHeadersUrl(`https://example.com/#${SHARE_HEADERS_FRAGMENT_KEY}=not-base64`)).toBeNull();
expect(parseSharedHeadersUrl(createUrl({ version: 2, headers: [{ name: 'X-Test', value: 'value' }] }))).toBeNull();
expect(parseSharedHeadersUrl(createUrl({ version: 1, headers: [{ name: '', value: '' }] }))).toBeNull();
});

it('creates an active selected-origin profile without cookies', () => {
const existingProfiles: Profile[] = [
{
id: '1',
requestHeaders: [{ id: 2, name: 'X-Existing', value: 'value', disabled: false }],
requestCookies: [],
urlFilters: [],
},
];
const parsed = parseSharedHeadersUrl(
createSharedHeadersUrl('http://localhost:3000', [
{ id: 3, name: 'Authorization', value: 'Bearer token', disabled: false },
]),
);

expect(parsed).not.toBeNull();
const profile = createProfileFromSharedHeaders(parsed as ParsedSharedHeadersUrl, existingProfiles);

expect(profile.id).not.toBe('1');
expect(profile.name).toBe('Shared: localhost');
expect(profile.requestHeaders).toEqual([
expect.objectContaining({ name: 'Authorization', value: 'Bearer token', disabled: false }),
]);
expect(profile.requestHeaders[0].id).not.toBe(2);
expect(profile.requestCookies).toEqual([]);
expect(profile.urlFilters).toEqual([expect.objectContaining({ value: 'http://localhost:3000', disabled: false })]);
});
});
70 changes: 70 additions & 0 deletions src/features/share-headers-by-url/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import browser from 'webextension-polyfill';

import type { Profile } from '#entities/request-profile/types';
import { BrowserStorageKey } from '#shared/constants';
import { logger } from '#shared/utils/logger';
import { setBrowserHeaders } from '#shared/utils/setBrowserHeaders';

import { createProfileFromSharedHeaders, parseSharedHeadersUrl, SHARE_HEADERS_FRAGMENT_KEY } from './utils';

const processingTabs = new Map<number, string>();

function readProfiles(value: unknown): Profile[] {
if (typeof value !== 'string') {
return [];
}

try {
const profiles = JSON.parse(value) as unknown;
return Array.isArray(profiles) ? (profiles as Profile[]) : [];
} catch {
return [];
}
}

export async function importSharedHeadersFromTab(tabId: number, tabUrl?: string) {
if (!tabUrl) {
return false;
}

const parsedUrl = parseSharedHeadersUrl(tabUrl);
if (!parsedUrl) {
if (tabUrl.includes(`${SHARE_HEADERS_FRAGMENT_KEY}=`)) {
logger.warn('Ignoring invalid shared headers payload');
}
return false;
}

if (processingTabs.get(tabId) === tabUrl) {
return false;
}

processingTabs.set(tabId, tabUrl);

try {
const storage = await browser.storage.local.get([BrowserStorageKey.Profiles, BrowserStorageKey.IsPaused]);
const profiles = readProfiles(storage[BrowserStorageKey.Profiles]);
const profile = createProfileFromSharedHeaders(parsedUrl, profiles);
const updatedProfiles = [...profiles, profile];
const updatedStorage = {
...storage,
[BrowserStorageKey.Profiles]: JSON.stringify(updatedProfiles),
[BrowserStorageKey.SelectedProfile]: profile.id,
};

await browser.storage.local.set({
[BrowserStorageKey.Profiles]: updatedStorage[BrowserStorageKey.Profiles],
[BrowserStorageKey.SelectedProfile]: profile.id,
});
await setBrowserHeaders(updatedStorage, parsedUrl.sanitizedUrl);
await browser.tabs.update(tabId, { url: parsedUrl.sanitizedUrl });

logger.info(`Imported shared headers for ${parsedUrl.origin}`);
return true;
} catch (error) {
logger.error('Failed to import shared headers from URL:', error);
return false;
} finally {
processingTabs.delete(tabId);
}
}
1 change: 1 addition & 0 deletions src/features/share-headers-by-url/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './utils';
Loading
Loading