diff --git a/CHANGELOG.md b/CHANGELOG.md index 697432f..18f20e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,22 @@ Future improvements planned: - Blog content creation for SEO - Multi-language support (Chinese, Japanese, Russian) +## [1.6.0] - 2026-04-20 + +### Added +- **Direct Connection mode**: new top-level mode that bypasses **all** proxies (including the OS-level / IE proxy that `System` falls back to). Surfaces as a dedicated button in the popup next to `System`. Closes a gap where users on Windows couldn't escape an IE-wide proxy without leaving the extension. ([#28](https://github.com/helebest/x-proxy/issues/28)) +- **Storage schema v2**: new top-level `mode: 'direct' | 'system' | 'profile'` field in `x-proxy-data`. Automatic one-way v1 → v2 migration infers `mode` from existing `activeProfileId`; stale ids are dropped safely. No user action required. +- **Regression guards**: new Vitest suite for migration edge cases (`tests/mode-migration.test.js`) and new Playwright spec for the Direct button (`e2e/direct-mode.spec.ts`). + +### Fixed +- **Toolbar icon color was delayed after profile activation**. Activating a profile from the popup did not immediately repaint the toolbar icon; it stayed gray until the user interacted with the address bar. The real root cause was that the icon logic only painted the profile color when the current tab's URL started with `http(s)://` — on `chrome://newtab`, `about:blank`, or any extension page it fell through to the inactive gray icon even with a profile active. Fixed by showing the profile color unconditionally when no per-domain routing rules are enabled (the simple case); per-tab indication is preserved for profiles that DO have routing rules since that's where it carries real information. The popup-window tab-query path was also hardened via `chrome.windows.getLastFocused({windowTypes:['normal']})` as a belt-and-braces improvement. + +### Changed +- **Visual polish pass** on the options page: added missing `--border-radius` / `--transition` design tokens (previously falling back to `0`, flattening inputs and killing hover transitions), added proper dark-mode support for the options page (previously hardcoded light), and aligned focus-ring and danger-hover colors with the iOS blue/red palette used throughout. + +### Credits +- Thanks to [@sergeevabc](https://github.com/sergeevabc) for reporting issue #28. + ## [1.5.2] - 2026-04-20 ### Fixed diff --git a/README.md b/README.md index 35cfbb1..ceffd3c 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,11 @@ If you find X-Proxy useful, consider: - [x] Darker modal overlay (`rgba(0,0,0,0.55)`) preserves visual separation without compositor cost - [x] New Playwright regression guard prevents `backdrop-filter` from slipping back in +### v1.6.0 ✅ (Direct Mode + UI Polish) +- [x] **Direct Connection mode** — a dedicated popup button that bypasses all proxies, including the OS-level / IE proxy that `System` otherwise honors ([#28](https://github.com/helebest/x-proxy/issues/28)) +- [x] **Schema v2 migration** — new top-level `mode` field replaces the implicit "no active profile = system" convention; automatic v1 → v2 upgrade for existing users +- [x] **Options page dark mode + token cleanup** — restored missing `--border-radius` / `--transition` tokens, added `prefers-color-scheme: dark` support, aligned focus/danger colors with the iOS palette + ### v2.0.0 (Future) - [ ] Profile sharing via URL - [ ] Connection testing diff --git a/background.js b/background.js index 7b23292..2740711 100644 --- a/background.js +++ b/background.js @@ -1,12 +1,29 @@ // X-Proxy Background Service Worker // Background script for proxy management with lifecycle management +import { migrateData, SCHEMA_VERSION } from './lib/storage-migration.js'; + console.log('X-Proxy background service worker loaded'); // Service worker state management let activeProfile = null; +let currentMode = 'system'; // 'direct' | 'system' | 'profile' let isInitialized = false; let keepAliveTimeout = null; +// Last color passed to updateIcon — exposed via GET_STATE so tests can +// assert toolbar-icon repaint without needing a real chrome.action.getIcon API. +let lastIconColor = null; + +// Read x-proxy-data and normalize to the canonical v2 shape. +async function readData() { + const result = await chrome.storage.local.get(['x-proxy-data']); + return migrateData(result['x-proxy-data']); +} + +// Persist the given v2-shaped data back to storage. +async function writeData(data) { + await chrome.storage.local.set({ 'x-proxy-data': { ...data, version: SCHEMA_VERSION } }); +} /** * Convert user-provided PAC URL or file path to Chrome proxy API format. @@ -103,6 +120,7 @@ const COLOR_NAMES = { // profileColor set → pre-rendered colored icon (site is actively proxied). // profileColor null → gray inactive icon (system proxy, routing bypass, or non-HTTP page). function updateIcon(profileColor = null) { + lastIconColor = profileColor; const name = profileColor ? COLOR_NAMES[profileColor] : null; chrome.action @@ -150,19 +168,42 @@ function isHostProxied(hostname, profile) { return (mode || 'whitelist') === 'whitelist' ? matched : !matched; } -// Update the toolbar icon reflecting whether the active tab's site is actually proxied. -// Called on profile activation, tab switches, and URL navigations. -// If the service worker was restarted by a tab event, activeProfile is restored from storage. +// Returns the active tab from the last-focused NORMAL browser window. +// An extension popup is a window of type 'popup' and carries no browsing tabs, +// so chrome.tabs.query({currentWindow:true}) invoked while our popup has focus +// returns nothing — which is why the toolbar icon used to stay gray until the +// user interacted with the address bar (closing the popup) and fired onUpdated. +async function getActiveBrowserTab() { + try { + const win = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }); + if (!win || win.id === chrome.windows.WINDOW_ID_NONE) return null; + const [tab] = await chrome.tabs.query({ active: true, windowId: win.id }); + return tab || null; + } catch { + return null; + } +} + +// Update the toolbar icon. +// When a profile is active without per-domain routing rules, the proxy applies +// globally and the icon always shows the profile color — matches user +// expectation of immediate "proxy is on" feedback, including when the active +// tab is chrome://newtab, about:blank, or any other non-http page. +// When routing rules ARE enabled, the icon is per-tab: profile color if the +// current site is matched by the rules, gray otherwise — the point of the +// per-tab indicator is to tell the user which sites are actually going through +// the proxy vs direct. async function updateIconForActiveTab() { if (!isInitialized) { try { - const result = await chrome.storage.local.get(['x-proxy-data']); - const data = result['x-proxy-data'] || {}; - activeProfile = data.activeProfileId && data.profiles + const data = await readData(); + currentMode = data.mode; + activeProfile = data.mode === 'profile' && data.activeProfileId ? (data.profiles.find(p => p.id === data.activeProfileId) || null) : null; } catch { activeProfile = null; + currentMode = 'system'; } isInitialized = true; } @@ -172,12 +213,22 @@ async function updateIconForActiveTab() { return; } + const routingRules = activeProfile.config?.routingRules; + const hasRoutingRules = routingRules?.enabled && routingRules?.domains?.length > 0; + + if (!hasRoutingRules) { + // Simple proxy (or PAC) without per-domain routing: show profile color + // regardless of current tab URL. + updateIcon(activeProfile.color); + return; + } + try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = await getActiveBrowserTab(); const url = tab?.url || tab?.pendingUrl || ''; if (!url.startsWith('http://') && !url.startsWith('https://')) { - // New-tab pages, chrome://, etc. are never proxied + // Non-http pages are never routed through per-domain rules. updateIcon(null); return; } @@ -195,9 +246,8 @@ async function activateProxy(profileId) { keepAlive(); // Keep service worker alive during operation try { - // Get profile data from storage - const result = await chrome.storage.local.get(['x-proxy-data']); - const data = result['x-proxy-data'] || {}; + // Get profile data from storage (normalized to v2) + const data = await readData(); const profile = data.profiles?.find(p => p.id === profileId); if (!profile) { @@ -289,12 +339,14 @@ async function activateProxy(profileId) { scope: 'regular' }); - // Update storage with active profile + // Update storage with active profile in mode='profile' + data.mode = 'profile'; data.activeProfileId = profileId; - await chrome.storage.local.set({ 'x-proxy-data': data }); + await writeData(data); // Update internal state and icon activeProfile = profile; + currentMode = 'profile'; updateIconForActiveTab(); console.log('Activated proxy:', profile.name); @@ -326,14 +378,15 @@ async function deactivateProxy() { scope: 'regular' }); - // Update storage to clear active profile - const result = await chrome.storage.local.get(['x-proxy-data']); - const data = result['x-proxy-data'] || {}; + // Update storage: mode='system', no active profile + const data = await readData(); + data.mode = 'system'; data.activeProfileId = undefined; - await chrome.storage.local.set({ 'x-proxy-data': data }); + await writeData(data); // Update internal state and icon activeProfile = null; + currentMode = 'system'; updateIcon(null); // Gray icon for system proxy console.log('Deactivated proxy, using system settings'); @@ -349,12 +402,13 @@ async function deactivateProxy() { }); // Update storage and state even if system mode failed - const result = await chrome.storage.local.get(['x-proxy-data']); - const data = result['x-proxy-data'] || {}; + const data = await readData(); + data.mode = 'system'; data.activeProfileId = undefined; - await chrome.storage.local.set({ 'x-proxy-data': data }); + await writeData(data); activeProfile = null; + currentMode = 'system'; updateIcon(null); console.log('Fallback: Cleared proxy settings'); @@ -369,6 +423,35 @@ async function deactivateProxy() { } } +// Switch Chrome to direct mode — bypasses OS proxy and PAC. +async function setDirectMode() { + keepAlive(); + + try { + await chrome.proxy.settings.clear({ scope: 'regular' }); + await new Promise(resolve => setTimeout(resolve, 100)); + await chrome.proxy.settings.set({ + value: { mode: 'direct' }, + scope: 'regular' + }); + + const data = await readData(); + data.mode = 'direct'; + data.activeProfileId = undefined; + await writeData(data); + + activeProfile = null; + currentMode = 'direct'; + updateIcon(null); + + console.log('Switched to direct mode (no proxy)'); + return { success: true }; + } catch (error) { + console.error('Failed to set direct mode:', error); + return { success: false, error: error?.message || String(error) || 'Unknown error' }; + } +} + // Initialize proxy state from storage async function initializeProxyState() { if (isInitialized) return; // Prevent multiple initializations @@ -377,11 +460,10 @@ async function initializeProxyState() { console.log('Initializing proxy state...'); try { - const result = await chrome.storage.local.get(['x-proxy-data']); - const data = result['x-proxy-data'] || {}; + const data = await readData(); + currentMode = data.mode; - // Check if there's an active profile - if (data.activeProfileId && data.profiles) { + if (data.mode === 'profile' && data.activeProfileId) { const profile = data.profiles.find(p => p.id === data.activeProfileId); if (profile) { activeProfile = profile; @@ -392,10 +474,10 @@ async function initializeProxyState() { } } - // No active profile, use system proxy + // No active profile (system or direct mode) activeProfile = null; - updateIcon(null); // Gray icon for system proxy - console.log('No active proxy, using system settings'); + updateIcon(null); + console.log(`Initialized in ${data.mode} mode`); isInitialized = true; console.log('Proxy state initialization completed'); @@ -404,6 +486,7 @@ async function initializeProxyState() { console.error('Failed to initialize proxy state:', error); // Default to system proxy (gray) on error activeProfile = null; + currentMode = 'system'; updateIcon(null); isInitialized = true; // Mark as initialized even on error } @@ -443,12 +526,12 @@ chrome.webRequest.onAuthRequired.addListener( } // Fallback: read from storage (service worker may have restarted) - chrome.storage.local.get(['x-proxy-data']).then(result => { - const data = result['x-proxy-data'] || {}; - if (data.activeProfileId && data.profiles) { + readData().then(data => { + if (data.mode === 'profile' && data.activeProfileId) { const profile = data.profiles.find(p => p.id === data.activeProfileId); if (profile) { activeProfile = profile; // Restore in-memory state + currentMode = 'profile'; const auth = profile.config?.auth; if (auth && auth.username) { console.log('Auth provided from storage for:', profile.name); @@ -495,6 +578,11 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { sendResponse(deactivateResult); break; + case 'SET_DIRECT_MODE': + const directResult = await setDirectMode(); + sendResponse(directResult); + break; + case 'GET_STATE': // Ensure initialization before returning state if (!isInitialized) { @@ -502,8 +590,11 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { } sendResponse({ success: true, + mode: currentMode, activeProfile: activeProfile, - isSystemProxy: !activeProfile + isSystemProxy: currentMode === 'system', + isDirectMode: currentMode === 'direct', + lastIconColor: lastIconColor }); break; @@ -529,7 +620,7 @@ chrome.tabs.onActivated.addListener(() => { chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { if (!changeInfo.url) return; - chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => { + getActiveBrowserTab().then(tab => { if (tab?.id === tabId) updateIconForActiveTab(); }).catch(() => {}); }); diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index c1b9994..d4882a8 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -88,11 +88,13 @@ X-Proxy respects your privacy: ### 🔄 Current Version -**Version 1.5.2** - Performance -• Removed backdrop blur effect for smooth UI on low-end hardware (no GPU required) -• Darker modal overlay preserves visual separation without compositor cost +**Version 1.6.0** - Direct Connection Mode + UI Polish +• New Direct Connection mode bypasses all proxies (including OS/IE-wide settings) +• Schema v2 with automatic one-way migration — existing users unaffected +• Options page now supports dark mode; refined focus rings and danger colors **Previous Updates:** +• v1.5.2: Removed backdrop blur effect for smooth UI on low-end hardware (no GPU required) • v1.5.1: Dynamic toolbar icon colors, dark mode polish • v1.5.0: PAC (Proxy Auto-Configuration) file support • v1.4.2: Proxy authentication (username/password) diff --git a/docs/index.html b/docs/index.html index e716f38..2fc1502 100644 --- a/docs/index.html +++ b/docs/index.html @@ -67,7 +67,7 @@ "worstRating": "1" }, "description": "Free Chrome proxy switcher with HTTP, HTTPS, SOCKS5, and PAC file support. Quick switching, profile management, and privacy-focused design.", - "softwareVersion": "1.5.2", + "softwareVersion": "1.6.0", "author": { "@type": "Person", "name": "helebest", diff --git a/e2e/__screenshots__/visual.spec.ts/popup-dark.png b/e2e/__screenshots__/visual.spec.ts/popup-dark.png index a96ab05..04438aa 100644 Binary files a/e2e/__screenshots__/visual.spec.ts/popup-dark.png and b/e2e/__screenshots__/visual.spec.ts/popup-dark.png differ diff --git a/e2e/__screenshots__/visual.spec.ts/popup-default.png b/e2e/__screenshots__/visual.spec.ts/popup-default.png index bc00637..31a7725 100644 Binary files a/e2e/__screenshots__/visual.spec.ts/popup-default.png and b/e2e/__screenshots__/visual.spec.ts/popup-default.png differ diff --git a/e2e/direct-mode.spec.ts b/e2e/direct-mode.spec.ts new file mode 100644 index 0000000..3d7255e --- /dev/null +++ b/e2e/direct-mode.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from './fixture' + +async function readStorageData(page: import('@playwright/test').Page) { + return page.evaluate(async () => { + const result = await chrome.storage.local.get(['x-proxy-data']) + return result['x-proxy-data'] + }) +} + +async function seedProfile(page: import('@playwright/test').Page, profile: Record) { + await page.evaluate(async (p) => { + const existing = (await chrome.storage.local.get(['x-proxy-data']))['x-proxy-data'] || {} + const data = { + ...existing, + profiles: [...(existing.profiles || []), p], + } + await chrome.storage.local.set({ 'x-proxy-data': data }) + }, profile) +} + +test.describe('Issue #28 — Direct Connection mode', () => { + test('popup exposes a Direct button next to System', async ({ popupPage }) => { + const directBtn = popupPage.locator('#directConnection') + const systemBtn = popupPage.locator('#systemProxy') + await expect(directBtn).toBeVisible() + await expect(systemBtn).toBeVisible() + await expect(directBtn).toContainText(/direct/i) + }) + + test('clicking Direct sets storage mode="direct" and updates status', async ({ popupPage }) => { + await popupPage.click('#directConnection') + await popupPage.waitForTimeout(300) + + const data = await readStorageData(popupPage) + expect(data?.mode).toBe('direct') + expect(data?.activeProfileId).toBeUndefined() + + await expect(popupPage.locator('#statusText')).toHaveText(/direct/i) + await expect(popupPage.locator('#directConnection')).toHaveClass(/selected/) + }) + + test('switching from Direct back to System sets mode="system"', async ({ popupPage }) => { + await popupPage.click('#directConnection') + await popupPage.waitForTimeout(200) + await popupPage.click('#systemProxy') + await popupPage.waitForTimeout(300) + + const data = await readStorageData(popupPage) + expect(data?.mode).toBe('system') + await expect(popupPage.locator('#statusText')).toHaveText('System') + await expect(popupPage.locator('#systemProxy')).toHaveClass(/selected/) + }) + + test('activating a profile after Direct clears direct state', async ({ popupPage }) => { + await seedProfile(popupPage, { + id: 'p-direct-test', + name: 'TestProxy', + color: '#007AFF', + config: { type: 'http', host: '127.0.0.1', port: 8888, auth: { username: '', password: '' } } + }) + await popupPage.reload() + await popupPage.waitForLoadState('domcontentloaded') + await popupPage.waitForTimeout(500) + + await popupPage.click('#directConnection') + await popupPage.waitForTimeout(200) + await popupPage.locator('.profile-item').first().click() + await popupPage.waitForTimeout(300) + + const data = await readStorageData(popupPage) + expect(data?.mode).toBe('profile') + expect(data?.activeProfileId).toBe('p-direct-test') + }) +}) diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index fbde5c3..1960683 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -462,7 +462,7 @@ test.describe('Options Page - About Section', () => { test('should navigate to About section', async ({ optionsPage }) => { await optionsPage.click('[data-section="about"]') await expect(optionsPage.locator('#about-section')).toBeVisible() - await expect(optionsPage.locator('#about-section')).toContainText('X-Proxy v1.5.2') + await expect(optionsPage.locator('#about-section')).toContainText('X-Proxy v1.6.0') }) test('should show feature list', async ({ optionsPage }) => { diff --git a/e2e/popup-window-icon.spec.ts b/e2e/popup-window-icon.spec.ts new file mode 100644 index 0000000..1d4df28 --- /dev/null +++ b/e2e/popup-window-icon.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from './fixture' + +// Regression guards for the toolbar-icon-repaint bugs where activating a +// profile from the popup left the icon gray until the user touched the +// address bar. Two distinct root causes are covered: +// +// 1. chrome.tabs.query({currentWindow:true}) returned empty when the popup +// was its own windowType:'popup' window. Fixed by looking up the last- +// focused NORMAL window explicitly. +// 2. Active tab was non-http (chrome://newtab, about:blank, etc.), so +// url.startsWith('http(s)://') returned false and updateIcon(null) ran +// even with a profile active — users expect immediate feedback that the +// proxy is on regardless of the current tab's scheme. Fixed by showing +// the profile color unconditionally when no per-domain routing rules +// are enabled. +// +// The earlier fixture opened popup.html in a regular http tab, masking both +// bugs. These specs pin down the real scenarios a user hits. + +const RED = '#F44336' + +function redProfile(routingEnabled = false, domains: string[] = []) { + return { + id: 'p-red', + name: 'Red', + color: RED, + config: { + type: 'http', + host: '127.0.0.1', + port: 8888, + auth: { username: '', password: '' }, + bypassList: [], + pacUrl: '', + routingRules: { enabled: routingEnabled, mode: 'whitelist', domains } + } + } +} + +async function seedProfile(page: import('@playwright/test').Page, profile: unknown) { + await page.evaluate(async (p) => { + await chrome.storage.local.set({ + 'x-proxy-data': { + version: 2, + mode: 'system', + profiles: [p], + activeProfileId: undefined, + settings: {} + } + }) + }, profile) +} + +async function openRealPopupWindow(context: import('@playwright/test').BrowserContext) { + const sw = context.serviceWorkers()[0] + const [page] = await Promise.all([ + context.waitForEvent('page'), + sw.evaluate(() => + chrome.windows.create({ + type: 'popup', + url: chrome.runtime.getURL('popup.html'), + focused: true, + width: 380, + height: 620 + }) + ) + ]) + await page.waitForLoadState('domcontentloaded') + await page.waitForTimeout(500) + return page +} + +async function readLastIconColor(page: import('@playwright/test').Page) { + const state = await page.evaluate(() => + new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_STATE' }, resolve)) + ) + expect(state.success).toBe(true) + return state.lastIconColor +} + +test('toolbar icon repaints when activating a profile from a real popup window on an http tab', async ({ context, extensionId }) => { + const browsingTab = await context.newPage() + await browsingTab.goto('https://example.com') + await browsingTab.waitForLoadState('domcontentloaded') + + const seedPage = await context.newPage() + await seedPage.goto(`chrome-extension://${extensionId}/popup.html`) + await seedPage.waitForLoadState('domcontentloaded') + await seedProfile(seedPage, redProfile()) + await seedPage.close() + + const popupPage = await openRealPopupWindow(context) + await popupPage.locator('.profile-item').first().click() + await popupPage.waitForTimeout(400) + + expect( + await readLastIconColor(popupPage), + 'icon should reflect activated profile color when current tab is http' + ).toBe(RED) +}) + +test('toolbar icon repaints even when the active tab is non-http (chrome://newtab, about:blank)', async ({ context, extensionId }) => { + // Seed profile without navigating anywhere — default tabs are about:blank. + // This is the exact scenario the user reported: click extension icon from + // the new-tab page, activate profile, icon should go to profile color. + const seedPage = await context.newPage() + await seedPage.goto(`chrome-extension://${extensionId}/popup.html`) + await seedPage.waitForLoadState('domcontentloaded') + await seedProfile(seedPage, redProfile()) + await seedPage.close() + + const popupPage = await openRealPopupWindow(context) + await popupPage.locator('.profile-item').first().click() + await popupPage.waitForTimeout(400) + + expect( + await readLastIconColor(popupPage), + 'icon should reflect activated profile color even with non-http active tab' + ).toBe(RED) +}) + +test('toolbar icon stays gray on a non-matching site when per-domain routing rules are enabled', async ({ context, extensionId }) => { + // With routing rules limited to *.example.com, a tab on wikipedia.org is + // legitimately NOT proxied — the per-tab indicator's job is to show that. + const browsingTab = await context.newPage() + await browsingTab.goto('https://www.wikipedia.org') + await browsingTab.waitForLoadState('domcontentloaded') + + const seedPage = await context.newPage() + await seedPage.goto(`chrome-extension://${extensionId}/popup.html`) + await seedPage.waitForLoadState('domcontentloaded') + await seedProfile(seedPage, redProfile(true, ['*.example.com'])) + await seedPage.close() + + const popupPage = await openRealPopupWindow(context) + await popupPage.locator('.profile-item').first().click() + await popupPage.waitForTimeout(400) + + expect( + await readLastIconColor(popupPage), + 'icon should be gray when routing rules exclude the current site' + ).toBe(null) +}) diff --git a/e2e/real-browser-action-popup.spec.ts b/e2e/real-browser-action-popup.spec.ts new file mode 100644 index 0000000..9e0fd62 --- /dev/null +++ b/e2e/real-browser-action-popup.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from './fixture' +import { openRealPopup, seedProfiles, makeProfile } from './real-popup' + +// End-to-end coverage of clicks inside the REAL toolbar popup (the one a user +// gets by clicking the extension icon), driven via chrome.action.openPopup() +// + raw CDP. See e2e/real-popup.ts for the bridge that works around +// Playwright's lack of Page support for browser-action popups. +// +// The active "browsing" tab is intentionally left on about:blank — that's the +// new-tab-page scenario where the earlier icon-repaint bug manifested. + +const RED = '#F44336' +const BLUE = '#007AFF' +const GREEN = '#4CAF50' + +async function setupBlankTab(context: import('@playwright/test').BrowserContext) { + const page = await context.newPage() + await page.bringToFront() + return page +} + +async function openPopupOrSkip( + context: import('@playwright/test').BrowserContext, + extensionId: string, + focusedPage: import('@playwright/test').Page +) { + const popup = await openRealPopup(context, extensionId, { focusedPage }) + if (!popup) { + test.skip(true, 'browser-action popup could not be opened on this Chromium build') + throw new Error('unreachable') + } + return popup +} + +test.describe('Real browser-action popup — click interactions', () => { + test('activating a profile repaints the toolbar icon with the profile color', async ({ context, extensionId }) => { + const focused = await setupBlankTab(context) + await seedProfiles(context, extensionId, [makeProfile({ id: 'p-red', color: RED })]) + + const popup = await openPopupOrSkip(context, extensionId, focused) + await popup.click('.profile-item') + + const state = await popup.getState() + expect(state.success).toBe(true) + expect(state.mode).toBe('profile') + expect(state.activeProfile?.id).toBe('p-red') + expect(state.lastIconColor).toBe(RED) + await popup.close() + }) + + test('clicking Direct switches mode to direct and clears icon color', async ({ context, extensionId }) => { + const focused = await setupBlankTab(context) + await seedProfiles(context, extensionId, [makeProfile({ id: 'p-red', color: RED })]) + + const popup = await openPopupOrSkip(context, extensionId, focused) + await popup.click('#directConnection') + + const state = await popup.getState() + expect(state.mode).toBe('direct') + expect(state.isDirectMode).toBe(true) + expect(state.activeProfile).toBeFalsy() + expect(state.lastIconColor).toBe(null) + await popup.close() + }) + + test('clicking System switches mode to system and clears icon color', async ({ context, extensionId }) => { + const focused = await setupBlankTab(context) + await seedProfiles(context, extensionId, [makeProfile({ id: 'p-red', color: RED })]) + + const popup = await openPopupOrSkip(context, extensionId, focused) + // activate a profile first so system is a real state transition, not the default + await popup.click('.profile-item') + await popup.click('#systemProxy') + + const state = await popup.getState() + expect(state.mode).toBe('system') + expect(state.isSystemProxy).toBe(true) + expect(state.activeProfile).toBeFalsy() + expect(state.lastIconColor).toBe(null) + await popup.close() + }) + + test('switching between profiles updates the icon color each time', async ({ context, extensionId }) => { + const focused = await setupBlankTab(context) + await seedProfiles(context, extensionId, [ + makeProfile({ id: 'p-red', name: 'Red', color: RED }), + makeProfile({ id: 'p-blue', name: 'Blue', color: BLUE }), + makeProfile({ id: 'p-green', name: 'Green', color: GREEN }), + ]) + + const popup = await openPopupOrSkip(context, extensionId, focused) + // Profiles render in insertion order. Click the third (green) first, then + // second (blue), then first (red) — verifies the icon tracks each click. + await popup.click('.profile-item:nth-child(3)') + let state = await popup.getState() + expect(state.activeProfile?.id).toBe('p-green') + expect(state.lastIconColor).toBe(GREEN) + + await popup.click('.profile-item:nth-child(2)') + state = await popup.getState() + expect(state.activeProfile?.id).toBe('p-blue') + expect(state.lastIconColor).toBe(BLUE) + + await popup.click('.profile-item:nth-child(1)') + state = await popup.getState() + expect(state.activeProfile?.id).toBe('p-red') + expect(state.lastIconColor).toBe(RED) + await popup.close() + }) + + test('profile → Direct → profile round-trip leaves the icon reflecting the latest selection', async ({ context, extensionId }) => { + const focused = await setupBlankTab(context) + await seedProfiles(context, extensionId, [makeProfile({ id: 'p-blue', color: BLUE })]) + + const popup = await openPopupOrSkip(context, extensionId, focused) + await popup.click('.profile-item') + expect((await popup.getState()).lastIconColor).toBe(BLUE) + + await popup.click('#directConnection') + expect((await popup.getState()).lastIconColor).toBe(null) + + await popup.click('.profile-item') + const state = await popup.getState() + expect(state.mode).toBe('profile') + expect(state.activeProfile?.id).toBe('p-blue') + expect(state.lastIconColor).toBe(BLUE) + await popup.close() + }) + + test('popup status text and selected state follow the active mode', async ({ context, extensionId }) => { + const focused = await setupBlankTab(context) + await seedProfiles(context, extensionId, [makeProfile({ id: 'p-red', name: 'Red', color: RED })]) + + const popup = await openPopupOrSkip(context, extensionId, focused) + + // Initial state — system. + expect(await popup.eval(`return document.querySelector('#statusText').textContent.trim();`)).toBe('System') + expect(await popup.eval(`return document.querySelector('#systemProxy').classList.contains('selected');`)).toBe(true) + + // Direct. + await popup.click('#directConnection') + expect(await popup.eval(`return document.querySelector('#statusText').textContent.trim();`)).toBe('Direct') + expect(await popup.eval(`return document.querySelector('#directConnection').classList.contains('selected');`)).toBe(true) + expect(await popup.eval(`return document.querySelector('#systemProxy').classList.contains('selected');`)).toBe(false) + + // Profile. + await popup.click('.profile-item') + expect(await popup.eval(`return document.querySelector('#statusText').textContent.trim();`)).toBe('Red') + expect(await popup.eval(`return document.querySelector('.profile-item').classList.contains('active');`)).toBe(true) + expect(await popup.eval(`return document.querySelector('#directConnection').classList.contains('selected');`)).toBe(false) + await popup.close() + }) +}) diff --git a/e2e/real-popup.ts b/e2e/real-popup.ts new file mode 100644 index 0000000..a5482cf --- /dev/null +++ b/e2e/real-popup.ts @@ -0,0 +1,197 @@ +import type { BrowserContext, Page } from '@playwright/test' + +// Thin wrapper that drives the REAL browser-action popup over raw CDP. +// Playwright does not expose toolbar popups as `context.pages()` entries, so +// we attach directly to the popup target via the browser-level CDPSession +// and pipe Runtime.evaluate through Target.sendMessageToTarget. +// +// Usage: +// const popup = await openRealPopup(context, extensionId) +// if (!popup) return test.skip(true, 'browser-action popup unavailable') +// await popup.click('#directConnection') +// const state = await popup.getState() +// await popup.close() + +const DEFAULT_TIMEOUT = 5000 + +export interface RealPopup { + click(selector: string): Promise + getState(): Promise + eval(source: string): Promise + close(): Promise +} + +// Opens the real extension popup and returns a handle for interaction. +// Returns null when Chromium refuses to open it (wrong focus state, older build, +// etc.) so callers can cleanly skip. +export async function openRealPopup( + context: BrowserContext, + extensionId: string, + options: { focusedPage?: Page; timeoutMs?: number } = {} +): Promise { + const { focusedPage, timeoutMs = DEFAULT_TIMEOUT } = options + + if (focusedPage) await focusedPage.bringToFront() + + const sw = context.serviceWorkers()[0] + if (!sw) return null + + const browserCdp = await context.browser()!.newBrowserCDPSession() + const popupUrl = `chrome-extension://${extensionId}/popup.html` + + try { + await sw.evaluate(() => chrome.action.openPopup()) + } catch { + // openPopup may reject when no normal window is focused; fall through to + // the polling loop — sometimes it opens anyway. + } + + // Poll Target.getTargets for the popup — it lands a few ms after openPopup(). + const deadline = Date.now() + timeoutMs + let targetId: string | null = null + while (Date.now() < deadline) { + const { targetInfos } = (await browserCdp.send('Target.getTargets')) as any + const hit = targetInfos.find( + (t: any) => t.type === 'page' && t.url.startsWith(popupUrl) + ) + if (hit) { + targetId = hit.targetId + break + } + await new Promise((r) => setTimeout(r, 100)) + } + if (!targetId) { + await browserCdp.detach().catch(() => {}) + return null + } + + const { sessionId } = (await browserCdp.send('Target.attachToTarget', { + targetId, + flatten: false, + })) as any + + let msgId = 0 + const pending = new Map void; reject: (e: any) => void }>() + browserCdp.on('Target.receivedMessageFromTarget', (ev: any) => { + if (ev.sessionId !== sessionId) return + const parsed = JSON.parse(ev.message) + const p = pending.get(parsed.id) + if (!p) return + pending.delete(parsed.id) + if (parsed.error) p.reject(new Error(parsed.error.message || 'CDP error')) + else p.resolve(parsed.result) + }) + + async function rawSend(method: string, params: Record = {}): Promise { + const id = ++msgId + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + browserCdp + .send('Target.sendMessageToTarget', { + sessionId, + message: JSON.stringify({ id, method, params }), + }) + .catch(reject) + }) + } + + async function evalInPopup(source: string): Promise { + const result: any = await rawSend('Runtime.evaluate', { + expression: `(async () => { ${source} })()`, + awaitPromise: true, + returnByValue: true, + }) + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || JSON.stringify(result.exceptionDetails)) + } + return result.result?.value + } + + // Wait for popup DOM content to render before the first interaction. + await evalInPopup(` + if (document.readyState !== 'complete') { + await new Promise(r => { + if (document.readyState === 'complete') r(); + else window.addEventListener('load', () => r(), { once: true }); + }); + } + await new Promise(r => setTimeout(r, 400)); + `) + + return { + async click(selector: string) { + await evalInPopup(` + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) throw new Error('selector not found: ' + ${JSON.stringify(selector)}); + el.click(); + await new Promise(r => setTimeout(r, 400)); + `) + }, + async getState() { + return evalInPopup(` + return await new Promise(resolve => + chrome.runtime.sendMessage({ type: 'GET_STATE' }, resolve) + ); + `) + }, + eval: evalInPopup, + async close() { + await browserCdp.detach().catch(() => {}) + }, + } +} + +// Helper: seed one or more profiles into storage via an extension page. +export async function seedProfiles( + context: BrowserContext, + extensionId: string, + profiles: Array> +): Promise { + const seedPage = await context.newPage() + await seedPage.goto(`chrome-extension://${extensionId}/popup.html`) + await seedPage.waitForLoadState('domcontentloaded') + await seedPage.evaluate(async (ps) => { + await chrome.storage.local.set({ + 'x-proxy-data': { + version: 2, + mode: 'system', + profiles: ps, + activeProfileId: undefined, + settings: {}, + }, + }) + }, profiles) + await seedPage.close() +} + +export function makeProfile( + overrides: Partial<{ + id: string + name: string + color: string + routingEnabled: boolean + routingDomains: string[] + }> = {} +) { + const { + id = 'p-' + Math.random().toString(36).slice(2, 8), + name = 'Test', + color = '#F44336', + routingEnabled = false, + routingDomains = [], + } = overrides + return { + id, + name, + color, + config: { + type: 'http', + host: '127.0.0.1', + port: 8888, + auth: { username: '', password: '' }, + bypassList: [], + pacUrl: '', + routingRules: { enabled: routingEnabled, mode: 'whitelist', domains: routingDomains }, + }, + } +} diff --git a/lib/storage-migration.js b/lib/storage-migration.js new file mode 100644 index 0000000..0a4fa3d --- /dev/null +++ b/lib/storage-migration.js @@ -0,0 +1,39 @@ +// Storage schema migration for X-Proxy. +// Converts any x-proxy-data payload (legacy, current, or absent) into the +// canonical v2 shape with an explicit `mode` field: 'direct' | 'system' | 'profile'. +// +// v1 → v2: infer `mode` from `activeProfileId`. +// activeProfileId matches a profile → mode='profile' +// otherwise (undefined, or stale) → mode='system' and activeProfileId dropped + +export const SCHEMA_VERSION = 2; +const VALID_MODES = new Set(['direct', 'system', 'profile']); + +export function migrateData(raw) { + const data = raw && typeof raw === 'object' ? raw : {}; + const profiles = Array.isArray(data.profiles) ? data.profiles : []; + const settings = data.settings && typeof data.settings === 'object' ? data.settings : {}; + + // 'profile' mode is only sound when activeProfileId still points at a real + // profile. This check is applied uniformly to both v1 inference and v2 + // passthrough so stale v2 state (e.g. a window where options.js cleared + // activeProfileId but didn't update mode yet) can't initialize the service + // worker into mode='profile' + activeProfile=null. + const activeIdValid = data.activeProfileId + && profiles.some(p => p && p.id === data.activeProfileId); + + let mode; + if (data.version === SCHEMA_VERSION && VALID_MODES.has(data.mode)) { + mode = data.mode === 'profile' && !activeIdValid ? 'system' : data.mode; + } else { + mode = activeIdValid ? 'profile' : 'system'; + } + + return { + version: SCHEMA_VERSION, + mode, + profiles, + activeProfileId: mode === 'profile' ? data.activeProfileId : undefined, + settings, + }; +} diff --git a/manifest.json b/manifest.json index e0dfa8a..766c0ed 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "X-Proxy", - "version": "1.5.2", + "version": "1.6.0", "description": "A modern proxy switcher for Chrome with HTTP(S) and SOCKS5 support", "permissions": [ "proxy", diff --git a/options.css b/options.css index adbb6ad..24e7066 100644 --- a/options.css +++ b/options.css @@ -26,6 +26,8 @@ --surface: #FFFFFF; --surface-hover: rgba(0, 0, 0, 0.04); --surface-active: rgba(0, 122, 255, 0.08); + --glass-bg: rgba(255, 255, 255, 0.8); + --glass-bg-strong: rgba(255, 255, 255, 0.95); /* Border & Dividers */ --border-color: rgba(60, 60, 67, 0.12); @@ -118,6 +120,51 @@ --gradient-warm: linear-gradient(135deg, #FF9500 0%, #FF3B30 100%); --gradient-cool: linear-gradient(135deg, #007AFF 0%, #5856D6 100%); --gradient-subtle: linear-gradient(180deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%); + + /* Legacy aliases — used throughout this file before the token rename. + Kept so var(--border-radius) / var(--transition) do not silently fall + back to 0 / "all 0s" (which was squaring off inputs and killing hover + transitions on btn-icon, form inputs, modal close, about links, etc.) */ + --border-radius: var(--radius-md); + --transition: all var(--transition-base); +} + +/* Dark mode support — mirrors popup.css tokens so the options page tracks + the system color scheme instead of forcing light. */ +@media (prefers-color-scheme: dark) { + :root { + --text-primary: #FFFFFF; + --text-secondary: #8E8E93; + --text-tertiary: #48484A; + + --background: #000000; + --background-secondary: #1C1C1E; + --surface: #1C1C1E; + --surface-hover: #2C2C2E; + --surface-active: rgba(0, 122, 255, 0.18); + --glass-bg: rgba(28, 28, 30, 0.8); + --glass-bg-strong: rgba(28, 28, 30, 0.95); + + --border-color: #38383A; + --border-hover: #48484A; + --divider: rgba(255, 255, 255, 0.08); + + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5), 0 4px 8px rgba(0, 0, 0, 0.2); + --shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.6), 0 6px 12px rgba(0, 0, 0, 0.2); + --shadow-2xl: 0 16px 32px rgba(0, 0, 0, 0.7), 0 8px 16px rgba(0, 0, 0, 0.2); + } + + /* A few spots reach past tokens and need explicit dark variants. */ + .modal { + background: rgba(0, 0, 0, 0.7); + } + + .password-toggle:hover { + background: rgba(255, 255, 255, 0.08); + } } /* Reset & Base Styles */ @@ -173,7 +220,7 @@ html { /* Header */ .header { - background: rgba(255, 255, 255, 0.8); + background: var(--glass-bg); border-bottom: 1px solid var(--border-color); position: fixed; top: 0; @@ -245,7 +292,7 @@ html { /* Sidebar */ .sidebar { width: var(--sidebar-width); - background: rgba(255, 255, 255, 0.95); + background: var(--glass-bg-strong); border-right: 1px solid var(--border-color); position: fixed; left: 0; @@ -478,7 +525,7 @@ html { } .btn-danger:hover { - background: #d32f2f; + background: var(--danger-hover); } .btn-icon { @@ -525,7 +572,7 @@ html { .form-textarea:focus { outline: none; border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); + box-shadow: 0 0 0 3px var(--primary-light); } .form-textarea { diff --git a/options.html b/options.html index 6354989..c6440fa 100644 --- a/options.html +++ b/options.html @@ -69,7 +69,7 @@

About X-Proxy

-

X-Proxy v1.5.2

+

X-Proxy v1.6.0

A modern proxy switcher for Chrome with HTTP(S), SOCKS5, and PAC support

diff --git a/options.js b/options.js index dc2580b..3e9f40f 100644 --- a/options.js +++ b/options.js @@ -66,7 +66,8 @@ class OptionsManager { getDefaultData() { return { - version: 1, + version: 2, + mode: 'system', profiles: [], activeProfileId: undefined, settings: this.getDefaultSettings() diff --git a/package-lock.json b/package-lock.json index b3aac3d..6bbbe5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "x-proxy", - "version": "1.5.2", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "x-proxy", - "version": "1.5.2", + "version": "1.6.0", "license": "MIT", "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/package.json b/package.json index 8352281..7c56256 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "x-proxy", - "version": "1.5.2", + "version": "1.6.0", "private": true, "type": "module", "scripts": { diff --git a/popup.css b/popup.css index f65dc5d..2765742 100644 --- a/popup.css +++ b/popup.css @@ -216,6 +216,11 @@ body::before { background: var(--primary-color); } +.status-indicator.direct .status-dot { + background: var(--success-color); + animation: none; +} + .status-text { font-size: 13px; font-weight: 500; @@ -747,7 +752,7 @@ body::before { } .profile-item { - animation: slideIn var(--transition-medium); + animation: slideIn var(--transition-base); } /* Responsive adjustments for smaller popup sizes */ diff --git a/popup.html b/popup.html index 98a651e..8166b63 100644 --- a/popup.html +++ b/popup.html @@ -26,11 +26,27 @@
- +