commit dded3d2fac3c229e675a3f8a2b8818222d985da7 Author: wt Date: Fri Oct 9 15:53:24 2026 +0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44ce34d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +web-ext-artifacts diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c89ff7a --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +default: build + +build: + web-ext build --overwrite-dest --filename proxyswitcherforkbywt.xpi + +clean: + rm -rvf web-ext-artifacts diff --git a/README.md b/README.md new file mode 100644 index 0000000..34b02d5 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Firefox Proxy Switcher + +A Firefox extension that allows selective application of proxy servers to specific domains, providing flexible proxy management for different websites. + +## Features + +- **Domain-specific proxy configuration**: Apply different proxy settings to different domains +- **Proxy profiles**: Save and reuse proxy configurations +- **HTTP and SOCKS5 support**: Support for both HTTP and SOCKS5 proxy types +- **Authentication support**: Username/password authentication for proxies +- **Tag-based organization**: Organize domains with custom tags +- **Quick toggle**: Enable/disable proxy for domains with one click + +## Security and Privacy + +This extension is designed with user privacy and security in mind: + +### Data Storage +- All configuration data (domains, profiles, proxy settings) is stored locally in the browser using Firefox's `browser.storage.local` API +- Proxy credentials (usernames/passwords) are encrypted and stored only in the local browser storage +- No data is transmitted to external servers or collected by the extension + +### Network Permissions +The extension requests the following permissions necessary for its functionality: + +- `proxy`: Required to configure and manage proxy settings +- `storage`: Required to save user configuration locally +- `tabs`: Required to determine the current domain when configuring proxy settings +- `activeTab`: Required to access the current tab for configuration +- `webRequest`: Required to handle proxy authentication requests +- `webRequestBlocking`: Required to intercept and modify network requests for proxy routing +- ``: Required to apply proxy settings to any website the user visits + +### How Proxy Routing Works +1. When a user visits a website, the extension checks if that domain has proxy configuration +2. If configured, the extension routes the traffic through the specified proxy server +3. If not configured, traffic uses the direct connection (no proxy) +4. Authentication credentials are only used when required by the proxy server and are transmitted securely + +### Security Considerations +- **No data collection**: The extension does not collect, store, or transmit any browsing data +- **Local-only storage**: All settings remain on the user's device +- **Transparent operation**: Users can see exactly which domains use which proxies +- **No external dependencies**: The extension works entirely offline once configured + +## Installation + +1. Download the extension package +2. Open Firefox and navigate to `about:addons` +3. Click the gear icon and select "Install Add-on From File" +4. Select the downloaded extension file +5. The extension will be installed and ready to use + +For Firefox Android, install the signed XPI from the extension's AMO page or use +Firefox's self-distributed add-on installation flow. The manifest declares Android +compatibility through `browser_specific_settings.gecko_android`; unsigned local +packages are still rejected by Firefox's extension signing policy. + +### Advanced Settings + +Open **Advanced Settings** from the extension popup to manage all domains and +profiles. The **Theme** selector supports **System**, **Light**, and **Dark** and +is stored locally for use in both the options page and popup. + +## Usage + +### Basic Setup +1. Click the Proxy Switcher icon in the Firefox toolbar +2. Configure proxy settings for the current domain or access full settings + +### Advanced Configuration +1. Right-click the extension icon and select "Options" +2. Add proxy profiles for reuse across domains +3. Configure domain-specific proxy settings +4. Use tags to organize your proxy configurations + +## Permissions Explanation + +### Required Permissions +- **proxy**: Allows the extension to configure Firefox's proxy settings +- **storage**: Enables saving your proxy configurations locally +- **tabs**: Lets the extension know which domain you're currently viewing +- **activeTab**: Provides access to the currently active tab for configuration +- **webRequest**: Handles authentication with proxy servers +- **webRequestBlocking**: Intercepts and routes network requests through proxies +- ****: Applies proxy settings to any website you visit + +All permissions are used solely for proxy management and do not enable data collection or tracking. + +## Support + +If you encounter issues or have questions: +1. Check that all required permissions are granted +2. Verify your proxy server settings are correct +3. Ensure the proxy server is accessible from your network + +## Version + +Current version: 1.0.1 diff --git a/background.js b/background.js new file mode 100644 index 0000000..f2e5d8f --- /dev/null +++ b/background.js @@ -0,0 +1,238 @@ +// Global configuration reference for backward compatibility +// configManager is available via window.configManager from config-manager.js +let config = window.configManager.getConfig(); + +// Initialization +async function init() { + await configManager.load(); + config = configManager.getConfig(); // Update global reference + setupProxyListener(); + + // Subscribe to configuration changes + configManager.addListener((event, data) => { + config = configManager.getConfig(); // Update global reference + }); +} + +// Setup proxy listener +// SECURITY NOTE: The extension only routes traffic through user-configured proxies. +// No data is collected or transmitted externally. All proxy settings are user-controlled. +function setupProxyListener() { + // Listen for all URLs to apply domain-specific proxy routing + // This permission is required but only routes traffic through user-specified proxies + browser.proxy.onRequest.addListener( + handleProxyRequest, + { urls: [""] } + ); + + // Listener for automatic proxy authentication + // SECURITY NOTE: Credentials are only used for proxy authentication and stored locally + browser.webRequest.onAuthRequired.addListener( + handleAuthRequired, + { urls: [""] }, + ["blocking"] + ); + + // Track tabs to determine origin domain for proxy configuration + // SECURITY NOTE: Only tracks domain names, no URL content or personal data + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'loading' && tab.url) { + updateTabOrigin(tabId, tab.url); + } + }); + + browser.tabs.onRemoved.addListener((tabId) => { + delete config.activeTabs[tabId]; + }); +} + +// Extract domain from URL +function getDomainFromUrl(url) { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ''); + } catch (e) { + return null; + } +} + +// Determine origin for tab +function updateTabOrigin(tabId, url) { + const domain = getDomainFromUrl(url); + if (domain) { + config.activeTabs[tabId] = domain; + } +} + +// Get proxy settings for domain +function getProxyConfigForDomain(domain) { + return config.domains[domain]; +} + +// Proxy request handler +function handleProxyRequest(requestInfo) { + const { tabId, url, originUrl, type } = requestInfo; + + // Determine request domain + let domain; + + if (originUrl) { + // For sub-requests use origin + domain = getDomainFromUrl(originUrl); + } else if (tabId in config.activeTabs) { + // For main requests use stored tab origin + domain = config.activeTabs[tabId]; + } else { + // Fallback option + domain = getDomainFromUrl(url); + } + + if (!domain) { + return { type: 'direct' }; + } + + // Check domain configuration + const proxyConfig = getProxyConfigForDomain(domain); + + if (!proxyConfig || !proxyConfig.enabled) { + return { type: 'direct' }; + } + + // Form proxy object + const proxyObj = { + type: proxyConfig.type, + host: proxyConfig.host, + port: parseInt(proxyConfig.port, 10) + }; + + // Add authentication if present + if (proxyConfig.username) { + proxyObj.username = proxyConfig.username; + } + + if (proxyConfig.password) { + proxyObj.password = proxyConfig.password; + } + + return proxyObj; +} + +// Authentication request handler +function handleAuthRequired(details) { + const { url, originUrl, tabId, isProxy, realm } = details; + + // Process only proxy authentication + if (!isProxy) { + return {}; + } + + // Determine request domain + let domain; + + if (originUrl) { + domain = getDomainFromUrl(originUrl); + } else if (tabId in config.activeTabs) { + domain = config.activeTabs[tabId]; + } else { + domain = getDomainFromUrl(url); + } + + if (!domain) { + return {}; + } + + // Get proxy configuration for domain + const proxyConfig = getProxyConfigForDomain(domain); + + if (!proxyConfig || !proxyConfig.enabled || !proxyConfig.username || !proxyConfig.password) { + return {}; + } + + // Return credentials for authentication + return { + authCredentials: { + username: proxyConfig.username, + password: proxyConfig.password + } + }; +} + +// Message handler from other extension parts +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === 'getConfig') { + sendResponse({ config: configManager.getConfig() }); + } else if (message.action === 'updateDomain') { + configManager.updateDomain(message.domain, message.domainConfig) + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; // Async response + } else if (message.action === 'removeDomain') { + configManager.removeDomain(message.domain) + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; // Async response + } else if (message.action === 'toggleProxy') { + // Quick enable/disable proxy for domain + const domain = message.domain; + const enabled = message.enabled; + const domainConfig = configManager.getDomainConfig(domain); + + if (domainConfig) { + // If domain is already configured, just change enabled + domainConfig.enabled = enabled; + configManager.updateDomain(domain, domainConfig) + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + } else { + // If domain is not configured, send error + sendResponse({ success: false, error: 'Domain not configured' }); + } + return true; // Async response + } else if (message.action === 'reloadConfig') { + configManager.reload() + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; // Async response + } else if (message.action === 'getProfiles') { + sendResponse({ profiles: configManager.getProfiles() }); + } else if (message.action === 'updateProfile') { + configManager.updateProfile(message.profileName, message.profileConfig) + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; // Async response + } else if (message.action === 'removeProfile') { + configManager.removeProfile(message.profileName) + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; // Async response + } else if (message.action === 'getProfile') { + sendResponse({ profile: configManager.getProfileConfig(message.profileName) }); + } else if (message.action === 'updateSettings') { + configManager.updateSettings(message.settings || {}) + .then(() => sendResponse({ success: true, settings: configManager.getConfig().settings })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; + } else if (message.action === 'clearAllDomains') { + configManager.config.domains = {}; + configManager.save() + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; + } else if (message.action === 'clearAllProfiles') { + configManager.config.profiles = {}; + configManager.save() + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; + } else if (message.action === 'clearEverything') { + configManager.config.domains = {}; + configManager.config.profiles = {}; + configManager.save() + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; + } +}); + +// Initialization on load +init(); diff --git a/config-manager.js b/config-manager.js new file mode 100644 index 0000000..0c22717 --- /dev/null +++ b/config-manager.js @@ -0,0 +1,146 @@ +// Centralized configuration manager for Firefox Proxy Switcher + +// Default configuration +const DEFAULT_CONFIG = { + domains: {}, + profiles: {}, // profileName -> profileConfig + settings: { + theme: 'system' + }, + activeTabs: {} // tabId -> domain for tracking active tabs +}; + +// Centralized configuration manager +class ConfigManager { + constructor() { + this.config = { ...DEFAULT_CONFIG }; + this.listeners = []; + } + + // Load configuration from storage + // SECURITY NOTE: Data is stored locally only, never transmitted externally + async load() { + try { + const stored = await browser.storage.local.get('config'); + this.config = { + ...DEFAULT_CONFIG, + ...stored.config, + settings: { ...DEFAULT_CONFIG.settings, ...(stored.config && stored.config.settings) } + }; + + this.notifyListeners('loaded'); + return this.config; + } catch (error) { + console.error('Failed to load config:', error); + throw error; + } + } + + // Save configuration to storage + // SECURITY NOTE: All proxy credentials and settings remain on user's device + async save() { + try { + await browser.storage.local.set({ config: this.config }); + + this.notifyListeners('saved'); + return true; + } catch (error) { + console.error('Failed to save config:', error); + throw error; + } + } + + // Reload configuration + async reload() { + return await this.load(); + } + + // Get current configuration + getConfig() { + return { ...this.config }; + } + + // Update general extension settings + async updateSettings(settings) { + this.config.settings = { ...this.config.settings, ...settings }; + await this.save(); + this.notifyListeners('settingsUpdated', this.config.settings); + } + + // Update domain + async updateDomain(domain, domainConfig) { + // Ensure tags is an array of strings + if (domainConfig.tags && typeof domainConfig.tags === 'string') { + domainConfig.tags = domainConfig.tags.split(',').map(tag => tag.trim()).filter(tag => tag); + } else if (!domainConfig.tags) { + domainConfig.tags = []; + } + this.config.domains[domain] = domainConfig; + await this.save(); + this.notifyListeners('domainUpdated', { domain, config: domainConfig }); + } + + // Remove domain + async removeDomain(domain) { + if (this.config.domains[domain]) { + delete this.config.domains[domain]; + await this.save(); + this.notifyListeners('domainRemoved', { domain }); + } + } + + // Get domain configuration + getDomainConfig(domain) { + return this.config.domains[domain] || null; + } + + // Update profile + async updateProfile(profileName, profileConfig) { + this.config.profiles[profileName] = profileConfig; + await this.save(); + this.notifyListeners('profileUpdated', { profileName, config: profileConfig }); + } + + // Remove profile + async removeProfile(profileName) { + if (this.config.profiles[profileName]) { + delete this.config.profiles[profileName]; + await this.save(); + this.notifyListeners('profileRemoved', { profileName }); + } + } + + // Get profile configuration + getProfileConfig(profileName) { + return this.config.profiles[profileName] || null; + } + + // Get all profiles + getProfiles() { + return { ...this.config.profiles }; + } + + // Add change listener + addListener(callback) { + this.listeners.push(callback); + } + + // Notify listeners + notifyListeners(event, data) { + this.listeners.forEach(callback => { + try { + callback(event, data); + } catch (error) { + console.error('Error in config listener:', error); + } + }); + } +} + +// Create global instance of configuration manager +const configManager = new ConfigManager(); + +// Create global variable for use in other scripts +window.ConfigManager = ConfigManager; +window.configManager = configManager; +window.DEFAULT_CONFIG = DEFAULT_CONFIG; diff --git a/icons/proxy-switcher-128.png b/icons/proxy-switcher-128.png new file mode 100644 index 0000000..fd06fe3 Binary files /dev/null and b/icons/proxy-switcher-128.png differ diff --git a/icons/proxy-switcher-16.png b/icons/proxy-switcher-16.png new file mode 100644 index 0000000..ad19257 Binary files /dev/null and b/icons/proxy-switcher-16.png differ diff --git a/icons/proxy-switcher-32.png b/icons/proxy-switcher-32.png new file mode 100644 index 0000000..7d4cf67 Binary files /dev/null and b/icons/proxy-switcher-32.png differ diff --git a/icons/proxy-switcher-48.png b/icons/proxy-switcher-48.png new file mode 100644 index 0000000..bad20e8 Binary files /dev/null and b/icons/proxy-switcher-48.png differ diff --git a/icons/proxy-switcher-512.png b/icons/proxy-switcher-512.png new file mode 100644 index 0000000..aa8b3f3 Binary files /dev/null and b/icons/proxy-switcher-512.png differ diff --git a/icons/proxy-switcher-64.png b/icons/proxy-switcher-64.png new file mode 100644 index 0000000..20db245 Binary files /dev/null and b/icons/proxy-switcher-64.png differ diff --git a/icons/proxy-switcher-96.png b/icons/proxy-switcher-96.png new file mode 100644 index 0000000..bf3c770 Binary files /dev/null and b/icons/proxy-switcher-96.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a97e45d --- /dev/null +++ b/manifest.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 2, + "name": "Proxy Switcher Fork By WT", + "version": "1.0.1", + "description": "Selectively apply proxy servers to specific domains. All settings are stored locally and no data is collected or transmitted externally.", + "icons": { + "16": "icons/proxy-switcher-16.png", + "32": "icons/proxy-switcher-32.png", + "48": "icons/proxy-switcher-48.png", + "64": "icons/proxy-switcher-64.png", + "96": "icons/proxy-switcher-96.png", + "128": "icons/proxy-switcher-128.png", + "512": "icons/proxy-switcher-512.png" + }, + "permissions": [ + "proxy", + "storage", + "tabs", + "activeTab", + "webRequest", + "webRequestBlocking", + "" + ], + "background": { + "scripts": ["config-manager.js", "background.js"], + "persistent": true + }, + "browser_action": { + "default_popup": "popup.html", + "default_icon": "icons/proxy-switcher-16.png" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "browser_specific_settings": { + "gecko": { + "id": "proxy-switcher-for-by-wt@9c0b2d3d", + "strict_min_version": "91.1.0", + "data_collection_permissions": { + "required": ["none"] + } + }, + "gecko_android": { + "strict_min_version": "91.1.0" + } + } +} diff --git a/options.css b/options.css new file mode 100644 index 0000000..bbc5fc8 --- /dev/null +++ b/options.css @@ -0,0 +1,511 @@ +:root { + --primary-color: #0066cc; + --secondary-color: #6c757d; + --success-color: #28a745; + --danger-color: #dc3545; + --background-color: #f8f9fa; + --card-background: #ffffff; + --border-color: #dee2e6; + --text-color: #212529; + --text-secondary: #6c757d; + --input-background: #ffffff; + --input-text: #212529; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --background-color: #202124; + --card-background: #2b2d31; + --border-color: #4a4d52; + --text-color: #f1f3f4; + --text-secondary: #bdc1c6; + --input-background: #303134; + --input-text: #f1f3f4; +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="system"] { + color-scheme: dark; + --background-color: #202124; + --card-background: #2b2d31; + --border-color: #4a4d52; + --text-color: #f1f3f4; + --text-secondary: #bdc1c6; + --input-background: #303134; + --input-text: #f1f3f4; + } +} + +select option { + background-color: var(--input-background); + color: var(--input-text); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-color); +} + +header h1 { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 14px; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.search-controls { + margin-bottom: 20px; +} + +.search-group { + display: flex; + gap: 10px; + align-items: center; +} + +.search-group input { + flex: 1; +} + +.search-group select { + flex: 0 0 20%; +} + +.profiles-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; + margin-bottom: 40px; +} + +.domains-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; + margin-bottom: 40px; +} + +.domain-card { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + transition: box-shadow 0.2s; +} + +.domain-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.profile-card { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + transition: box-shadow 0.2s; +} + +.profile-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.domain-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; +} + +.domain-name { + font-size: 16px; + font-weight: 600; + color: var(--primary-color); + margin-bottom: 5px; +} + +.domain-tags { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-top: 5px; +} + +.tag { + font-size: 11px; + padding: 2px 6px; + background-color: var(--secondary-color); + color: white; + border-radius: 10px; + font-weight: 500; +} + +.domain-status { + font-size: 12px; + padding: 4px 8px; + border-radius: 12px; + font-weight: 600; + margin-left: auto; +} + +.domain-status.enabled { + background-color: #d4edda; + color: #155724; +} + +.domain-status.disabled { + background-color: #f8d7da; + color: #721c24; +} + +.domain-details { + margin-bottom: 20px; +} + +.detail { + display: flex; + margin-bottom: 8px; + font-size: 14px; +} + +.detail-label { + font-weight: 600; + min-width: 80px; + color: var(--text-secondary); +} + +.detail-value { + color: var(--text-color); +} + +.domain-actions { + display: flex; + gap: 10px; +} + +.profile-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.profile-name { + font-size: 16px; + font-weight: 600; + color: var(--primary-color); +} + +.profile-details { + margin-bottom: 20px; +} + +.profile-actions { + display: flex; + gap: 10px; +} + +.domain-form-section { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 30px; + margin-bottom: 30px; +} + +.profile-form-section { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 30px; + margin-bottom: 30px; +} + +.advanced-settings-section { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 30px; + margin-bottom: 30px; +} + +.settings-row { + max-width: 600px; +} + +.profile-form { + max-width: 600px; +} + +.domain-form { + max-width: 600px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +label { + display: block; + margin-bottom: 6px; + font-weight: 600; + font-size: 14px; +} + +input, select { + width: 100%; + padding: 10px 12px; + background-color: var(--input-background); + color: var(--input-text); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + transition: border-color 0.2s; +} + +input:focus, select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); +} + +input::placeholder { + color: var(--text-secondary); +} + +input:invalid { + border-color: var(--danger-color); +} + +.form-hint { + display: block; + margin-top: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.password-input { + position: relative; +} + +.toggle-password { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 16px; + padding: 5px; +} + +.checkbox-group { + display: flex; + align-items: center; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: auto; +} + +.form-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 30px; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: #0052a3; +} + +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.empty-state { + text-align: center; + padding: 40px; + color: var(--text-secondary); + grid-column: 1 / -1; +} + +.hidden { + display: none !important; +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease; +} + +.modal-overlay:not(.hidden) { + opacity: 1; +} + +.domain-form-section:not(.hidden), +.profile-form-section:not(.hidden) { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.9); + z-index: 1001; + max-height: 90vh; + overflow-y: auto; + opacity: 0; + transition: all 0.3s ease; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.domain-form-section:not(.hidden), +.profile-form-section:not(.hidden) { + transform: translate(-50%, -50%) scale(1); + opacity: 1; +} + +body.modal-open { + overflow: hidden; +} + +footer { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--border-color); + text-align: center; + color: var(--text-secondary); + font-size: 12px; +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +@media (max-width: 768px) { + .container { + padding: 15px; + } + + .form-row { + grid-template-columns: 1fr; + gap: 0; + } + + .domains-list { + grid-template-columns: 1fr; + } + + .profiles-list { + grid-template-columns: 1fr; + } + + .footer-content { + flex-direction: column; + gap: 10px; + } +} diff --git a/options.html b/options.html new file mode 100644 index 0000000..d824624 --- /dev/null +++ b/options.html @@ -0,0 +1,248 @@ + + + + + + Proxy Switcher - Options + + + + + + +
+
+

Proxy Switcher Proxy Switcher

+

Manage domain-specific proxy settings

+
+ +
+
+
+

Proxy Profiles

+ +
+ +
+ +
+
+ +
+
+

Configured Domains

+ +
+ +
+
+ + +
+
+ +
+ +
+
+ + + + +
+

Advanced Settings

+
+ + + Choose light, dark, or follow your device theme. +
+
+
+

Management

+
+ + + +
+
+
+ +
+ +
+
+ + + + diff --git a/options.js b/options.js new file mode 100644 index 0000000..310ad78 --- /dev/null +++ b/options.js @@ -0,0 +1,940 @@ +class OptionsManager { + constructor() { + this.currentEditDomain = null; + this.currentEditProfile = null; + this.searchQuery = ''; + this.tagFilter = ''; + this.allDomains = {}; // For storing all domains for filtering + this.init(); + } + + // Sanitizer for user data + sanitize(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + async init() { + await this.loadDomains(); + await this.loadProfiles(); + await this.loadSettings(); + this.setupEventListeners(); + } + + async loadSettings() { + try { + const response = await browser.runtime.sendMessage({ action: 'getConfig' }); + const theme = response.config && response.config.settings && response.config.settings.theme || 'system'; + document.getElementById('theme-select').value = theme; + this.applyTheme(theme); + } catch (error) { + console.error('Failed to load settings:', error); + this.applyTheme('system'); + } + } + + applyTheme(theme) { + document.documentElement.dataset.theme = ['light', 'dark', 'system'].includes(theme) ? theme : 'system'; + } + + async saveTheme(theme) { + const response = await browser.runtime.sendMessage({ action: 'updateSettings', settings: { theme } }); + if (!response.success) throw new Error(response.error || 'Failed to save theme'); + this.applyTheme(theme); + } + + async loadDomains() { + try { + const response = await browser.runtime.sendMessage({ action: 'getConfig' }); + const config = response.config || { domains: {} }; + + this.allDomains = config.domains; + this.updateTagFilter(); + this.renderDomainsList(this.filterDomains()); + } catch (error) { + console.error('Failed to load domains:', error); + this.showError('Failed to load configuration'); + } + } + + renderDomainsList(domains) { + const container = document.getElementById('domains-list'); + container.innerHTML = ''; // Clear the container + + if (Object.keys(domains).length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.innerHTML = ` +

No domains configured yet.

+

Click "Add Domain" to get started.

+ `; + container.appendChild(emptyState); + return; + } + + const domainsList = Object.entries(domains) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([domain, config]) => this.createDomainCard(domain, config)); + + domainsList.forEach(card => { + container.appendChild(card); + }); + + // Add event handlers for buttons + document.querySelectorAll('.edit-domain').forEach(btn => { + btn.addEventListener('click', (e) => { + const domain = e.target.dataset.domain; + this.editDomain(domain); + }); + }); + + document.querySelectorAll('.delete-domain').forEach(btn => { + btn.addEventListener('click', (e) => { + const domain = e.target.dataset.domain; + this.deleteDomain(domain); + }); + }); + } + + createDomainCard(domain, config) { + const typeMap = { + http: 'HTTP', + socks: 'SOCKS5' + }; + + const statusClass = config.enabled ? 'enabled' : 'disabled'; + const statusText = config.enabled ? 'Enabled' : 'Disabled'; + + const card = document.createElement('div'); + card.className = 'domain-card'; + + // Header + const header = document.createElement('div'); + header.className = 'domain-header'; + + const headerContent = document.createElement('div'); + + const domainName = document.createElement('h3'); + domainName.className = 'domain-name'; + domainName.textContent = this.sanitize(domain); + + headerContent.appendChild(domainName); + + if (config.tags && config.tags.length) { + const tagsContainer = document.createElement('div'); + tagsContainer.className = 'domain-tags'; + config.tags.forEach(tag => { + const tagSpan = document.createElement('span'); + tagSpan.className = 'tag'; + tagSpan.textContent = this.sanitize(tag); + tagsContainer.appendChild(tagSpan); + }); + headerContent.appendChild(tagsContainer); + } + + header.appendChild(headerContent); + + const status = document.createElement('span'); + status.className = `domain-status ${statusClass}`; + status.textContent = statusText; + header.appendChild(status); + + card.appendChild(header); + + // Details + const details = document.createElement('div'); + details.className = 'domain-details'; + + const typeDetail = document.createElement('div'); + typeDetail.className = 'detail'; + const typeLabel = document.createElement('span'); + typeLabel.className = 'detail-label'; + typeLabel.textContent = 'Type:'; + const typeValue = document.createElement('span'); + typeValue.className = 'detail-value'; + typeValue.textContent = typeMap[config.type] || config.type; + typeDetail.appendChild(typeLabel); + typeDetail.appendChild(typeValue); + details.appendChild(typeDetail); + + const serverDetail = document.createElement('div'); + serverDetail.className = 'detail'; + const serverLabel = document.createElement('span'); + serverLabel.className = 'detail-label'; + serverLabel.textContent = 'Server:'; + const serverValue = document.createElement('span'); + serverValue.className = 'detail-value'; + serverValue.textContent = `${this.sanitize(config.host)}:${config.port}`; + serverDetail.appendChild(serverLabel); + serverDetail.appendChild(serverValue); + details.appendChild(serverDetail); + + if (config.username) { + const usernameDetail = document.createElement('div'); + usernameDetail.className = 'detail'; + const usernameLabel = document.createElement('span'); + usernameLabel.className = 'detail-label'; + usernameLabel.textContent = 'Username:'; + const usernameValue = document.createElement('span'); + usernameValue.className = 'detail-value'; + usernameValue.textContent = this.sanitize(config.username); + usernameDetail.appendChild(usernameLabel); + usernameDetail.appendChild(usernameValue); + details.appendChild(usernameDetail); + } + + card.appendChild(details); + + // Actions + const actions = document.createElement('div'); + actions.className = 'domain-actions'; + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-secondary edit-domain'; + editBtn.dataset.domain = domain; + editBtn.textContent = 'Edit'; + actions.appendChild(editBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-danger delete-domain'; + deleteBtn.dataset.domain = domain; + deleteBtn.textContent = 'Delete'; + actions.appendChild(deleteBtn); + + card.appendChild(actions); + + return card; + } + + showDomainForm(domain = null, config = null) { + this.currentEditDomain = domain; + + const formSection = document.getElementById('domain-form-section'); + const formTitle = document.getElementById('form-title'); + const form = document.getElementById('domain-form'); + const overlay = document.getElementById('modal-overlay'); + + if (domain) { + formTitle.textContent = `Edit ${domain}`; + this.populateForm(config); + } else { + formTitle.textContent = 'Add Domain'; + form.reset(); + } + + // Show modal window + overlay.classList.remove('hidden'); + formSection.classList.remove('hidden'); + document.body.classList.add('modal-open'); + + document.getElementById('domain-input').focus(); + } + + hideDomainForm() { + const formSection = document.getElementById('domain-form-section'); + const overlay = document.getElementById('modal-overlay'); + + // Hide modal window + overlay.classList.add('hidden'); + formSection.classList.add('hidden'); + document.body.classList.remove('modal-open'); + + this.currentEditDomain = null; + const form = document.getElementById('domain-form'); + form.reset(); + } + + populateForm(config) { + document.getElementById('domain-input').value = this.currentEditDomain; + document.getElementById('proxy-type').value = config.type || 'http'; + document.getElementById('proxy-host').value = config.host || ''; + document.getElementById('proxy-port').value = config.port || ''; + document.getElementById('proxy-username').value = config.username || ''; + document.getElementById('proxy-password').value = config.password || ''; + document.getElementById('domain-tags').value = config.tags ? config.tags.join(', ') : ''; + document.getElementById('proxy-enabled').checked = config.enabled !== false; + } + + async saveDomain(event) { + event.preventDefault(); + + const form = event.target; + + // Validation + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + const domain = document.getElementById('domain-input').value.trim().toLowerCase(); + const tagsInput = document.getElementById('domain-tags').value.trim(); + const domainConfig = { + type: document.getElementById('proxy-type').value, + host: document.getElementById('proxy-host').value.trim(), + port: parseInt(document.getElementById('proxy-port').value, 10), + username: document.getElementById('proxy-username').value.trim() || null, + password: document.getElementById('proxy-password').value || null, + enabled: document.getElementById('proxy-enabled').checked, + tags: tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : [] + }; + + // Additional validation + if (domainConfig.port < 1 || domainConfig.port > 65535) { + this.showError('Port must be between 1 and 65535'); + return; + } + + try { + // If domain name has changed, remove the old domain + if (this.currentEditDomain && this.currentEditDomain !== domain) { + const removeResponse = await browser.runtime.sendMessage({ + action: 'removeDomain', + domain: this.currentEditDomain + }); + if (!removeResponse.success) { + throw new Error(removeResponse.error || 'Failed to remove old domain'); + } + } + + // Use centralized API to update domain + const response = await browser.runtime.sendMessage({ + action: 'updateDomain', + domain: domain, + domainConfig: domainConfig + }); + + if (response.success) { + this.showSuccess(`Domain ${domain} saved successfully`); + this.hideDomainForm(); + await this.loadDomains(); + } else { + throw new Error(response.error || 'Failed to save domain'); + } + + } catch (error) { + console.error('Failed to save domain:', error); + this.showError('Failed to save domain configuration'); + } + } + + async deleteDomain(domain) { + if (!confirm(`Are you sure you want to remove proxy settings for ${domain}?`)) { + return; + } + + try { + // Use centralized API to remove domain + const response = await browser.runtime.sendMessage({ + action: 'removeDomain', + domain: domain + }); + + if (response.success) { + this.showSuccess(`Domain ${domain} removed`); + await this.loadDomains(); + } else { + throw new Error(response.error || 'Failed to delete domain'); + } + + } catch (error) { + console.error('Failed to delete domain:', error); + this.showError('Failed to delete domain'); + } + } + + async editDomain(domain) { + try { + const response = await browser.runtime.sendMessage({ action: 'getConfig' }); + const config = response.config || { domains: {} }; + + if (config.domains[domain]) { + this.showDomainForm(domain, config.domains[domain]); + } + } catch (error) { + console.error('Failed to load domain:', error); + this.showError('Failed to load domain configuration'); + } + } + + // Methods for profiles + async loadProfiles() { + try { + const response = await browser.runtime.sendMessage({ action: 'getProfiles' }); + const profiles = response.profiles || {}; + + this.renderProfilesList(profiles); + this.updateProfileSelect(profiles); + } catch (error) { + console.error('Failed to load profiles:', error); + this.showError('Failed to load profiles'); + } + } + + renderProfilesList(profiles) { + const container = document.getElementById('profiles-list'); + container.innerHTML = ''; // Clear the container + + if (Object.keys(profiles).length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.innerHTML = ` +

No profiles configured yet.

+

Click "Add Profile" to create your first profile.

+ `; + container.appendChild(emptyState); + return; + } + + const profilesList = Object.entries(profiles) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([profileName, config]) => this.createProfileCard(profileName, config)); + + profilesList.forEach(card => { + container.appendChild(card); + }); + + // Add event handlers for buttons + document.querySelectorAll('.edit-profile').forEach(btn => { + btn.addEventListener('click', (e) => { + const profileName = e.target.dataset.profile; + this.editProfile(profileName); + }); + }); + + document.querySelectorAll('.delete-profile').forEach(btn => { + btn.addEventListener('click', (e) => { + const profileName = e.target.dataset.profile; + this.deleteProfile(profileName); + }); + }); + } + + createProfileCard(profileName, config) { + const typeMap = { + http: 'HTTP', + socks: 'SOCKS5' + }; + + const card = document.createElement('div'); + card.className = 'profile-card'; + + // Header + const header = document.createElement('div'); + header.className = 'profile-header'; + + const profileNameEl = document.createElement('h3'); + profileNameEl.className = 'profile-name'; + profileNameEl.textContent = this.sanitize(profileName); + + header.appendChild(profileNameEl); + card.appendChild(header); + + // Details + const details = document.createElement('div'); + details.className = 'profile-details'; + + const typeDetail = document.createElement('div'); + typeDetail.className = 'detail'; + const typeLabel = document.createElement('span'); + typeLabel.className = 'detail-label'; + typeLabel.textContent = 'Type:'; + const typeValue = document.createElement('span'); + typeValue.className = 'detail-value'; + typeValue.textContent = typeMap[config.type] || config.type; + typeDetail.appendChild(typeLabel); + typeDetail.appendChild(typeValue); + details.appendChild(typeDetail); + + const serverDetail = document.createElement('div'); + serverDetail.className = 'detail'; + const serverLabel = document.createElement('span'); + serverLabel.className = 'detail-label'; + serverLabel.textContent = 'Server:'; + const serverValue = document.createElement('span'); + serverValue.className = 'detail-value'; + serverValue.textContent = `${this.sanitize(config.host)}:${config.port}`; + serverDetail.appendChild(serverLabel); + serverDetail.appendChild(serverValue); + details.appendChild(serverDetail); + + if (config.username) { + const usernameDetail = document.createElement('div'); + usernameDetail.className = 'detail'; + const usernameLabel = document.createElement('span'); + usernameLabel.className = 'detail-label'; + usernameLabel.textContent = 'Username:'; + const usernameValue = document.createElement('span'); + usernameValue.className = 'detail-value'; + usernameValue.textContent = this.sanitize(config.username); + usernameDetail.appendChild(usernameLabel); + usernameDetail.appendChild(usernameValue); + details.appendChild(usernameDetail); + } + + card.appendChild(details); + + // Actions + const actions = document.createElement('div'); + actions.className = 'profile-actions'; + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-secondary edit-profile'; + editBtn.dataset.profile = profileName; + editBtn.textContent = 'Edit'; + actions.appendChild(editBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-danger delete-profile'; + deleteBtn.dataset.profile = profileName; + deleteBtn.textContent = 'Delete'; + actions.appendChild(deleteBtn); + + card.appendChild(actions); + + return card; + } + + updateProfileSelect(profiles) { + const select = document.getElementById('profile-select'); + const currentValue = select.value; + + // Clear the select, keeping only the default option + select.innerHTML = ''; + + // Add profiles + Object.keys(profiles).sort().forEach(profileName => { + const option = document.createElement('option'); + option.value = profileName; + option.textContent = profileName; + select.appendChild(option); + }); + + // Restore selected value if it still exists + if (currentValue && profiles[currentValue]) { + select.value = currentValue; + } + } + + showProfileForm(profileName = null, config = null) { + this.currentEditProfile = profileName; + + const formSection = document.getElementById('profile-form-section'); + const formTitle = document.getElementById('profile-form-title'); + const form = document.getElementById('profile-form'); + const overlay = document.getElementById('modal-overlay'); + + if (profileName) { + formTitle.textContent = `Edit ${profileName}`; + this.populateProfileForm(config); + } else { + formTitle.textContent = 'Add Profile'; + form.reset(); + } + + // Show modal window + overlay.classList.remove('hidden'); + formSection.classList.remove('hidden'); + document.body.classList.add('modal-open'); + + document.getElementById('profile-name-input').focus(); + } + + hideProfileForm() { + const formSection = document.getElementById('profile-form-section'); + const overlay = document.getElementById('modal-overlay'); + + // Hide modal window + overlay.classList.add('hidden'); + formSection.classList.add('hidden'); + document.body.classList.remove('modal-open'); + + this.currentEditProfile = null; + const form = document.getElementById('profile-form'); + form.reset(); + } + + populateProfileForm(config) { + document.getElementById('profile-name-input').value = this.currentEditProfile; + document.getElementById('profile-proxy-type').value = config.type || 'http'; + document.getElementById('profile-proxy-host').value = config.host || ''; + document.getElementById('profile-proxy-port').value = config.port || ''; + document.getElementById('profile-proxy-username').value = config.username || ''; + document.getElementById('profile-proxy-password').value = config.password || ''; + } + + async saveProfile(event) { + event.preventDefault(); + + const form = event.target; + + // Validation + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + const profileName = document.getElementById('profile-name-input').value.trim(); + const profileConfig = { + type: document.getElementById('profile-proxy-type').value, + host: document.getElementById('profile-proxy-host').value.trim(), + port: parseInt(document.getElementById('profile-proxy-port').value, 10), + username: document.getElementById('profile-proxy-username').value.trim() || null, + password: document.getElementById('profile-proxy-password').value || null + }; + + // Additional validation + if (profileConfig.port < 1 || profileConfig.port > 65535) { + this.showError('Port must be between 1 and 65535'); + return; + } + + try { + // If profile name has changed, remove the old profile + if (this.currentEditProfile && this.currentEditProfile !== profileName) { + const removeResponse = await browser.runtime.sendMessage({ + action: 'removeProfile', + profileName: this.currentEditProfile + }); + if (!removeResponse.success) { + throw new Error(removeResponse.error || 'Failed to remove old profile'); + } + } + + const response = await browser.runtime.sendMessage({ + action: 'updateProfile', + profileName: profileName, + profileConfig: profileConfig + }); + + if (response.success) { + this.showSuccess(`Profile "${profileName}" saved successfully`); + this.hideProfileForm(); + await this.loadProfiles(); + } else { + throw new Error(response.error || 'Failed to save profile'); + } + + } catch (error) { + console.error('Failed to save profile:', error); + this.showError('Failed to save profile'); + } + } + + async deleteProfile(profileName) { + if (!confirm(`Are you sure you want to delete profile "${profileName}"?`)) { + return; + } + + try { + const response = await browser.runtime.sendMessage({ + action: 'removeProfile', + profileName: profileName + }); + + if (response.success) { + this.showSuccess(`Profile "${profileName}" deleted`); + await this.loadProfiles(); + } else { + throw new Error(response.error || 'Failed to delete profile'); + } + + } catch (error) { + console.error('Failed to delete profile:', error); + this.showError('Failed to delete profile'); + } + } + + async editProfile(profileName) { + try { + const response = await browser.runtime.sendMessage({ action: 'getProfile', profileName }); + const profile = response.profile; + + if (profile) { + this.showProfileForm(profileName, profile); + } + } catch (error) { + console.error('Failed to load profile:', error); + this.showError('Failed to load profile'); + } + } + + // Fill domain form with profile data + fillFormFromProfile(profileName) { + if (!profileName) { + // If empty profile is selected, do nothing + return; + } + + // Get profile data + browser.runtime.sendMessage({ action: 'getProfile', profileName }) + .then(response => { + const profile = response.profile; + if (profile) { + // Fill domain form fields + document.getElementById('proxy-type').value = profile.type || 'http'; + document.getElementById('proxy-host').value = profile.host || ''; + document.getElementById('proxy-port').value = profile.port || ''; + document.getElementById('proxy-username').value = profile.username || ''; + document.getElementById('proxy-password').value = profile.password || ''; + } + }) + .catch(error => { + console.error('Failed to load profile for form:', error); + }); + } + + // Filter domains by search and tags + filterDomains() { + let filtered = { ...this.allDomains }; + + // Search filter + if (this.searchQuery) { + const query = this.searchQuery.toLowerCase(); + filtered = Object.fromEntries( + Object.entries(filtered).filter(([domain]) => + domain.toLowerCase().includes(query) + ) + ); + } + + // Tag filter + if (this.tagFilter) { + filtered = Object.fromEntries( + Object.entries(filtered).filter(([, config]) => + config.tags && config.tags.includes(this.tagFilter) + ) + ); + } + + return filtered; + } + + // Update tag filter + updateTagFilter() { + const tagFilter = document.getElementById('tag-filter'); + const currentValue = tagFilter.value; + + // Collect all unique tags + const allTags = new Set(); + Object.values(this.allDomains).forEach(config => { + if (config.tags) { + config.tags.forEach(tag => allTags.add(tag)); + } + }); + + // Clear and add options + tagFilter.innerHTML = ''; + Array.from(allTags).sort().forEach(tag => { + const option = document.createElement('option'); + option.value = tag; + option.textContent = tag; + tagFilter.appendChild(option); + }); + + // Restore selected value + if (currentValue && allTags.has(currentValue)) { + tagFilter.value = currentValue; + } + } + + // Update domain display when filters change + updateDomainDisplay() { + this.renderDomainsList(this.filterDomains()); + } + + // Methods for bulk deletion + async clearAllDomains() { + if (!confirm('Are you sure you want to delete ALL domain configurations? This action cannot be undone.')) { + return; + } + + try { + const response = await browser.runtime.sendMessage({ action: 'clearAllDomains' }); + if (response.success) { + this.showSuccess('All domains cleared'); + await this.loadDomains(); + } else { + throw new Error(response.error || 'Failed to clear domains'); + } + } catch (error) { + console.error('Failed to clear domains:', error); + this.showError('Failed to clear domains'); + } + } + + async clearAllProfiles() { + if (!confirm('Are you sure you want to delete ALL profile configurations? This action cannot be undone.')) { + return; + } + + try { + const response = await browser.runtime.sendMessage({ action: 'clearAllProfiles' }); + if (response.success) { + this.showSuccess('All profiles cleared'); + await this.loadProfiles(); + } else { + throw new Error(response.error || 'Failed to clear profiles'); + } + } catch (error) { + console.error('Failed to clear profiles:', error); + this.showError('Failed to clear profiles'); + } + } + + async clearEverything() { + if (!confirm('Are you sure you want to delete ALL configurations (domains and profiles)? This action cannot be undone.')) { + return; + } + + try { + const response = await browser.runtime.sendMessage({ action: 'clearEverything' }); + if (response.success) { + this.showSuccess('All configurations cleared'); + await this.loadDomains(); + await this.loadProfiles(); + } else { + throw new Error(response.error || 'Failed to clear everything'); + } + } catch (error) { + console.error('Failed to clear everything:', error); + this.showError('Failed to clear everything'); + } + } + + showMessage(message, type = 'info') { + const messageDiv = document.createElement('div'); + messageDiv.className = `message message-${type}`; + messageDiv.textContent = message; + messageDiv.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 4px; + color: white; + z-index: 1000; + animation: slideIn 0.3s ease; + `; + + if (type === 'success') { + messageDiv.style.backgroundColor = '#28a745'; + } else if (type === 'error') { + messageDiv.style.backgroundColor = '#dc3545'; + } else { + messageDiv.style.backgroundColor = '#17a2b8'; + } + + document.body.appendChild(messageDiv); + + setTimeout(() => { + messageDiv.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => messageDiv.remove(), 300); + }, 3000); + } + + showSuccess(message) { + this.showMessage(message, 'success'); + } + + showError(message) { + this.showMessage(message, 'error'); + } + + setupEventListeners() { + // Modal overlay - close on click + document.getElementById('modal-overlay').addEventListener('click', () => { + // Determine which form is open + const domainForm = document.getElementById('domain-form-section'); + const profileForm = document.getElementById('profile-form-section'); + + if (!domainForm.classList.contains('hidden')) { + this.hideDomainForm(); + } else if (!profileForm.classList.contains('hidden')) { + this.hideProfileForm(); + } + }); + + // Add domain + document.getElementById('add-domain').addEventListener('click', () => { + this.showDomainForm(); + }); + + // Domain form + document.getElementById('domain-form').addEventListener('submit', + (e) => this.saveDomain(e)); + + // Cancel editing + document.getElementById('cancel-form').addEventListener('click', () => { + this.hideDomainForm(); + }); + + // Show/hide password + document.querySelectorAll('.toggle-password').forEach(btn => { + btn.addEventListener('click', (e) => { + const targetId = e.target.dataset.target; + const input = document.getElementById(targetId); + const type = input.type === 'password' ? 'text' : 'password'; + input.type = type; + e.target.textContent = type === 'password' ? '👁' : '👁️‍🗨️'; + }); + }); + + // Profiles + document.getElementById('add-profile').addEventListener('click', () => { + this.showProfileForm(); + }); + + document.getElementById('profile-form').addEventListener('submit', + (e) => this.saveProfile(e)); + + document.getElementById('cancel-profile-form').addEventListener('click', () => { + this.hideProfileForm(); + }); + + // Fill domain form from profile + document.getElementById('profile-select').addEventListener('change', (e) => { + this.fillFormFromProfile(e.target.value); + }); + + // Domain search + document.getElementById('domain-search').addEventListener('input', (e) => { + this.searchQuery = e.target.value.trim(); + this.updateDomainDisplay(); + }); + + // Tag filter + document.getElementById('tag-filter').addEventListener('change', (e) => { + this.tagFilter = e.target.value; + this.updateDomainDisplay(); + }); + + // Control buttons + document.getElementById('clear-domains').addEventListener('click', () => { + this.clearAllDomains(); + }); + + document.getElementById('clear-profiles').addEventListener('click', () => { + this.clearAllProfiles(); + }); + + document.getElementById('clear-all').addEventListener('click', () => { + this.clearEverything(); + }); + + document.getElementById('theme-select').addEventListener('change', async (e) => { + try { + await this.saveTheme(e.target.value); + this.showSuccess('Theme saved'); + } catch (error) { + console.error('Failed to save theme:', error); + this.showError('Failed to save theme'); + } + }); + } +} + +// Initialize on load +document.addEventListener('DOMContentLoaded', () => { + new OptionsManager(); +}); diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..812c1b2 --- /dev/null +++ b/popup.css @@ -0,0 +1,245 @@ +:root { + --primary-color: #0066cc; + --secondary-color: #6c757d; + --background-color: #ffffff; + --border-color: #dee2e6; + --text-color: #212529; + --text-secondary: #6c757d; + --muted-background: #f8f9fa; + --input-background: #ffffff; + --input-text: #212529; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --background-color: #202124; + --border-color: #4a4d52; + --text-color: #f1f3f4; + --text-secondary: #bdc1c6; + --muted-background: #35363a; + --input-background: #303134; + --input-text: #f1f3f4; +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="system"] { + color-scheme: dark; + --background-color: #202124; + --border-color: #4a4d52; + --text-color: #f1f3f4; + --text-secondary: #bdc1c6; + --muted-background: #35363a; + --input-background: #303134; + --input-text: #f1f3f4; + } +} + +select option { + background-color: var(--input-background); + color: var(--input-text); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-size: 14px; + color: var(--text-color); + min-width: 320px; +} + +.popup-container { + padding: 16px; + background-color: var(--background-color); +} + +.popup-header { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.popup-header h2 { + font-size: 18px; + margin-bottom: 8px; + color: var(--primary-color); +} + +.domain-display { + font-size: 12px; + color: var(--text-secondary); + font-family: monospace; + background-color: var(--muted-background); + padding: 4px 8px; + border-radius: 4px; + display: inline-block; +} + +.toggle-section { + margin-bottom: 20px; +} + +.toggle-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.toggle-slider { + position: relative; + width: 40px; + height: 20px; + background-color: #ccc; + border-radius: 20px; + transition: background-color 0.3s; +} + +.toggle-slider:after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: white; + top: 2px; + left: 2px; + transition: transform 0.3s; +} + +input[type="checkbox"] { + display: none; +} + +input[type="checkbox"]:checked + .toggle-slider { + background-color: var(--primary-color); +} + +input[type="checkbox"]:checked + .toggle-slider:after { + transform: translateX(20px); +} + +.toggle-text { + font-weight: 600; +} + +.proxy-form { + margin-top: 20px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +label { + display: block; + margin-bottom: 4px; + font-weight: 600; + font-size: 12px; +} + +input, select { + width: 100%; + padding: 8px 10px; + background-color: var(--input-background); + color: var(--input-text); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 13px; +} + +input:focus, select:focus { + outline: none; + border-color: var(--primary-color); +} + +input::placeholder { + color: var(--text-secondary); +} + +.password-input { + position: relative; +} + +.toggle-password { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 2px; +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 24px; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex: 1; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: #0052a3; +} + +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +.info-message { + text-align: center; + padding: 20px; + color: var(--text-secondary); + font-size: 12px; +} + +.info-message p { + margin-bottom: 8px; +} + +.popup-footer { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border-color); + text-align: center; +} + +.hint { + font-size: 11px; + color: var(--text-secondary); +} + +.hidden { + display: none !important; +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..adb86a7 --- /dev/null +++ b/popup.html @@ -0,0 +1,97 @@ + + + + + + Proxy Switcher + + + + + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..65b9c32 --- /dev/null +++ b/popup.js @@ -0,0 +1,308 @@ +class PopupManager { + constructor() { + this.currentTab = null; + this.currentDomain = null; + this.currentConfig = null; + this.init(); + } + + async init() { + await this.loadCurrentTab(); + await this.loadConfig(); + await this.loadProfiles(); + this.setupUI(); + this.setupEventListeners(); + } + + async loadCurrentTab() { + try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + this.currentTab = tabs[0]; + + if (this.currentTab.url) { + this.currentDomain = this.extractDomain(this.currentTab.url); + document.getElementById('current-domain').textContent = this.currentDomain; + } + } catch (error) { + console.error('Failed to load current tab:', error); + document.getElementById('current-domain').textContent = 'Unknown domain'; + } + } + + extractDomain(url) { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ''); + } catch (e) { + return null; + } + } + + async loadConfig() { + try { + const response = await browser.runtime.sendMessage({ action: 'getConfig' }); + const config = response.config || { domains: {} }; + this.applyTheme(config.settings && config.settings.theme); + this.currentConfig = this.currentDomain && config.domains + ? config.domains[this.currentDomain] || null + : null; + } catch (error) { + console.error('Failed to load config:', error); + this.currentConfig = null; + } + } + + applyTheme(theme) { + document.documentElement.dataset.theme = ['light', 'dark', 'system'].includes(theme) ? theme : 'system'; + } + + async loadProfiles() { + try { + const response = await browser.runtime.sendMessage({ action: 'getProfiles' }); + const profiles = response.profiles || {}; + this.updateProfileSelect(profiles); + } catch (error) { + console.error('Failed to load profiles:', error); + } + } + + updateProfileSelect(profiles) { + const select = document.getElementById('popup-profile-select'); + + // Clear select, keeping only default option + select.innerHTML = ''; + + // Add profiles + Object.keys(profiles).sort().forEach(profileName => { + const option = document.createElement('option'); + option.value = profileName; + option.textContent = profileName; + select.appendChild(option); + }); + } + + // Fill form with profile data + fillFormFromProfile(profileName) { + if (!profileName) { + // If empty profile selected, do nothing + return; + } + + // Get profile data + browser.runtime.sendMessage({ action: 'getProfile', profileName }) + .then(response => { + const profile = response.profile; + if (profile) { + // Fill form fields + document.getElementById('popup-proxy-type').value = profile.type || 'http'; + document.getElementById('popup-proxy-host').value = profile.host || ''; + document.getElementById('popup-proxy-port').value = profile.port || ''; + document.getElementById('popup-proxy-username').value = profile.username || ''; + document.getElementById('popup-proxy-password').value = profile.password || ''; + } + }) + .catch(error => { + console.error('Failed to load profile for form:', error); + }); + } + + setupUI() { + const enableCheckbox = document.getElementById('enable-proxy'); + const proxyForm = document.getElementById('proxy-form'); + const noProxyInfo = document.getElementById('no-proxy-info'); + + if (this.currentConfig) { + // Proxy configured for this domain + enableCheckbox.checked = Boolean(this.currentConfig.enabled); + enableCheckbox.disabled = false; + // Show/hide form based on checkbox state + this.updateFormVisibility(enableCheckbox.checked); + noProxyInfo.classList.add('hidden'); + + // Populate form + this.populateForm(); + } else { + // Proxy not configured + enableCheckbox.checked = false; + enableCheckbox.disabled = !this.currentDomain; + proxyForm.classList.add('hidden'); + noProxyInfo.classList.remove('hidden'); + } + } + + updateFormVisibility(showForm) { + const proxyForm = document.getElementById('proxy-form'); + proxyForm.classList.toggle('hidden', !showForm); + } + + populateForm() { + if (!this.currentConfig) return; + + document.getElementById('popup-proxy-type').value = this.currentConfig.type || 'http'; + document.getElementById('popup-proxy-host').value = this.currentConfig.host || ''; + document.getElementById('popup-proxy-port').value = this.currentConfig.port || ''; + document.getElementById('popup-proxy-username').value = this.currentConfig.username || ''; + document.getElementById('popup-proxy-password').value = this.currentConfig.password || ''; + } + + async saveConfig() { + if (!this.currentDomain) return; + + // Collect form data + const domainConfig = { + type: document.getElementById('popup-proxy-type').value, + host: document.getElementById('popup-proxy-host').value.trim(), + port: parseInt(document.getElementById('popup-proxy-port').value, 10), + username: document.getElementById('popup-proxy-username').value.trim() || null, + password: document.getElementById('popup-proxy-password').value || null, + enabled: document.getElementById('enable-proxy').checked + }; + + // Validation + if (!domainConfig.host || !domainConfig.port) { + this.showError('Host and port are required'); + return false; + } + + if (domainConfig.port < 1 || domainConfig.port > 65535) { + this.showError('Port must be between 1 and 65535'); + return false; + } + + try { + // Use centralized API for domain update + const response = await browser.runtime.sendMessage({ + action: 'updateDomain', + domain: this.currentDomain, + domainConfig: domainConfig + }); + + return response.success; + } catch (error) { + console.error('Failed to save config:', error); + this.showError('Failed to save configuration'); + return false; + } + } + + async removeDomainConfig() { + try { + // Use centralized API for domain removal + const response = await browser.runtime.sendMessage({ + action: 'removeDomain', + domain: this.currentDomain + }); + + return response.success; + } catch (error) { + console.error('Failed to remove config:', error); + return false; + } + } + + async toggleProxy(enabled) { + if (!this.currentDomain) return; + + try { + const response = await browser.runtime.sendMessage({ + action: 'toggleProxy', + domain: this.currentDomain, + enabled: enabled + }); + + if (!response.success) { + // If domain not configured, show message + this.showError('Domain not configured. Use "Apply & Reload" to configure proxy first.'); + // Return checkbox to previous state + document.getElementById('enable-proxy').checked = !enabled; + } + } catch (error) { + console.error('Failed to toggle proxy:', error); + this.showError('Failed to toggle proxy'); + // Return checkbox to previous state + document.getElementById('enable-proxy').checked = !enabled; + } + } + + async applyAndReload() { + const success = await this.saveConfig(); + + if (success && this.currentTab) { + // Reload tab + await browser.tabs.reload(this.currentTab.id); + + // Close popup + window.close(); + } + } + + showError(message) { + // Simple error display implementation + const errorDiv = document.createElement('div'); + errorDiv.className = 'error-message'; + errorDiv.textContent = message; + errorDiv.style.cssText = ` + background-color: #f8d7da; + color: #721c24; + padding: 10px; + margin: 10px 0; + border-radius: 4px; + border: 1px solid #f5c6cb; + `; + + const main = document.querySelector('.popup-main'); + main.insertBefore(errorDiv, main.firstChild); + + setTimeout(() => errorDiv.remove(), 3000); + } + + setupEventListeners() { + // Proxy toggle (quick enable/disable) + document.getElementById('enable-proxy').addEventListener('change', async (e) => { + const enabled = e.target.checked; + this.updateFormVisibility(enabled); // Show/hide form + // A new domain has no stored configuration yet; Apply & Reload saves it. + if (this.currentConfig) { + await this.toggleProxy(enabled); + } + }); + + // Apply settings + document.getElementById('apply-proxy').addEventListener('click', () => { + this.applyAndReload(); + }); + + // Open settings + document.getElementById('open-options').addEventListener('click', () => { + browser.runtime.openOptionsPage(); + window.close(); + }); + + // Show/hide password + document.querySelector('.toggle-password').addEventListener('click', (e) => { + const input = document.getElementById('popup-proxy-password'); + const type = input.type === 'password' ? 'text' : 'password'; + input.type = type; + e.target.textContent = type === 'password' ? '👁' : '👁️‍🗨️'; + }); + + // Handle Enter in form + document.querySelectorAll('input').forEach(input => { + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this.applyAndReload(); + } + }); + }); + + // Fill form from profile + document.getElementById('popup-profile-select').addEventListener('change', (e) => { + this.fillFormFromProfile(e.target.value); + }); + } +} + +// Initialization +document.addEventListener('DOMContentLoaded', () => { + new PopupManager(); +});