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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 123 additions & 32 deletions background.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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');
Expand All @@ -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
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -495,15 +578,23 @@ 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) {
await initializeProxyState();
}
sendResponse({
success: true,
mode: currentMode,
activeProfile: activeProfile,
isSystemProxy: !activeProfile
isSystemProxy: currentMode === 'system',
isDirectMode: currentMode === 'direct',
lastIconColor: lastIconColor
});
break;

Expand All @@ -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(() => {});
});
Expand Down
8 changes: 5 additions & 3 deletions docs/STORE_LISTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file modified e2e/__screenshots__/visual.spec.ts/popup-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/__screenshots__/visual.spec.ts/popup-default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading