diff --git a/background.js b/background.js index 96ce33c..97b85b1 100644 --- a/background.js +++ b/background.js @@ -7,6 +7,8 @@ importScripts( 'background/account-run-history.js', 'background/contribution-oauth.js', 'background/mail-2925-session.js', + 'background/ip-proxy-provider-711proxy.js', + 'background/ip-proxy-core.js', 'background/panel-bridge.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', @@ -211,6 +213,43 @@ const CONTRIBUTION_SOURCE_SUB2API = 'sub2api'; const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池'; const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus'; const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback'; +const DEFAULT_IP_PROXY_SERVICE = '711proxy'; +const IP_PROXY_SERVICE_VALUES = ['711proxy', 'lumiproxy', 'iproyal', 'omegaproxy']; +const IP_PROXY_ENABLED_SERVICE_VALUES = ['711proxy']; +const DEFAULT_IP_PROXY_MODE = 'account'; +const IP_PROXY_MODE_VALUES = ['api', 'account']; +const DEFAULT_IP_PROXY_PROTOCOL = 'http'; +const IP_PROXY_PROTOCOL_VALUES = ['http', 'https', 'socks4', 'socks5']; +const IP_PROXY_FETCH_TIMEOUT_MS = 20000; +const IP_PROXY_SETTINGS_SCOPE = 'regular'; +const IP_PROXY_BYPASS_LIST = ['', 'localhost', '127.0.0.1']; +const IP_PROXY_ROUTE_ALL_TRAFFIC = true; +const IP_PROXY_ACCOUNT_LIST_ENABLED = false; +const IP_PROXY_INIT_ENABLE_EXIT_PROBE = false; +const IP_PROXY_INIT_SUPPRESS_AUTH_REBIND = true; +const IP_PROXY_INIT_AUTO_APPLY = false; +const IP_PROXY_TARGET_HOST_PATTERNS = [ + 'openai.com', + '*.openai.com', + 'chatgpt.com', + '*.chatgpt.com', + 'ipwho.is', + '*.ipwho.is', + 'ipapi.co', + '*.ipapi.co', + 'ipinfo.io', + '*.ipinfo.io', + 'api.ipify.org', + 'api64.ipify.org', + 'api.ip.cc', + 'ifconfig.me', + 'checkip.amazonaws.com', + 'ipv4.icanhazip.com', + 'ident.me', + 'httpbin.org', + 'ip-api.com', + 'myip.ipip.net', +]; const AUTO_RUN_TIMER_ALARM_NAME = 'auto-run-timer'; const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds'; @@ -376,6 +415,21 @@ const PERSISTED_SETTING_DEFAULTS = { sub2apiPassword: '', sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME, sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME, + ipProxyEnabled: false, + ipProxyService: DEFAULT_IP_PROXY_SERVICE, + ipProxyMode: DEFAULT_IP_PROXY_MODE, + ipProxyApiUrl: '', + ipProxyServiceProfiles: {}, + ipProxyAccountList: '', + ipProxyAccountSessionPrefix: '', + ipProxyAccountLifeMinutes: '', + ipProxyPoolTargetCount: '20', + ipProxyHost: '', + ipProxyPort: '', + ipProxyProtocol: DEFAULT_IP_PROXY_PROTOCOL, + ipProxyUsername: '', + ipProxyPassword: '', + ipProxyRegion: '', codex2apiUrl: DEFAULT_CODEX2API_URL, codex2apiAdminKey: '', customPassword: '', @@ -485,6 +539,15 @@ const DEFAULT_STATE = { sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 logs: [], // 侧边栏展示的运行日志。 ...PERSISTED_SETTING_DEFAULTS, // 合并 chrome.storage.local 中持久化保存的用户配置。 + ipProxyApiPool: [], + ipProxyApiCurrentIndex: 0, + ipProxyApiCurrent: null, + ipProxyAccountPool: [], + ipProxyAccountCurrentIndex: 0, + ipProxyAccountCurrent: null, + ipProxyPool: [], + ipProxyCurrentIndex: 0, + ipProxyCurrent: null, luckmailApiKey: '', luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL, luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE, @@ -515,6 +578,21 @@ const DEFAULT_STATE = { currentHotmailAccountId: null, currentMail2925AccountId: null, preferredIcloudHost: '', + ipProxyApplied: false, + ipProxyAppliedReason: 'disabled', + ipProxyAppliedAt: 0, + ipProxyAppliedHost: '', + ipProxyAppliedPort: 0, + ipProxyAppliedRegion: '', + ipProxyAppliedHasAuth: false, + ipProxyAppliedProvider: DEFAULT_IP_PROXY_SERVICE, + ipProxyAppliedError: '', + ipProxyAppliedWarning: '', + ipProxyAppliedExitIp: '', + ipProxyAppliedExitRegion: '', + ipProxyAppliedExitDetecting: false, + ipProxyAppliedExitError: '', + ipProxyAppliedExitSource: '', }; function normalizeAutoRunDelayMinutes(value) { @@ -1097,6 +1175,63 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'sub2apiDefaultProxyName': return String(value || '').trim(); + case 'ipProxyEnabled': + return Boolean(value); + case 'ipProxyService': + return normalizeIpProxyProviderValue(value); + case 'ipProxyMode': + return normalizeIpProxyMode(value); + case 'ipProxyApiUrl': + return String(value || '').trim(); + case 'ipProxyServiceProfiles': + return normalizeIpProxyServiceProfiles(value || {}, PERSISTED_SETTING_DEFAULTS); + case 'ipProxyAccountList': + return normalizeIpProxyAccountList(value || ''); + case 'ipProxyAccountSessionPrefix': + return normalizeIpProxyAccountSessionPrefix(value || ''); + case 'ipProxyAccountLifeMinutes': + return normalizeIpProxyAccountLifeMinutes(value || ''); + case 'ipProxyPoolTargetCount': + return normalizeIpProxyPoolTargetCount(value || '', 20); + case 'ipProxyHost': + return String(value || '').trim(); + case 'ipProxyPort': + return String(normalizeIpProxyPort(value || '') || ''); + case 'ipProxyProtocol': + return normalizeIpProxyProtocol(value); + case 'ipProxyUsername': + return String(value || '').trim(); + case 'ipProxyPassword': + return String(value || ''); + case 'ipProxyRegion': + return String(value || '').trim(); + case 'ipProxyApiPool': + return normalizeProxyPoolEntries( + value, + normalizeIpProxyProviderValue(value?.provider || DEFAULT_IP_PROXY_SERVICE) + ); + case 'ipProxyApiCurrentIndex': + return normalizeIpProxyCurrentIndex(value, 0); + case 'ipProxyApiCurrent': + return normalizeProxyPoolEntries(value ? [value] : [], DEFAULT_IP_PROXY_SERVICE)[0] || null; + case 'ipProxyAccountPool': + return normalizeProxyPoolEntries( + value, + normalizeIpProxyProviderValue(value?.provider || DEFAULT_IP_PROXY_SERVICE) + ); + case 'ipProxyAccountCurrentIndex': + return normalizeIpProxyCurrentIndex(value, 0); + case 'ipProxyAccountCurrent': + return normalizeProxyPoolEntries(value ? [value] : [], DEFAULT_IP_PROXY_SERVICE)[0] || null; + case 'ipProxyPool': + return normalizeProxyPoolEntries( + value, + normalizeIpProxyProviderValue(value?.provider || DEFAULT_IP_PROXY_SERVICE) + ); + case 'ipProxyCurrentIndex': + return normalizeIpProxyCurrentIndex(value, 0); + case 'ipProxyCurrent': + return normalizeProxyPoolEntries(value ? [value] : [], DEFAULT_IP_PROXY_SERVICE)[0] || null; case 'codex2apiUrl': return normalizeCodex2ApiUrl(value); case 'codex2apiAdminKey': @@ -1241,6 +1376,34 @@ function buildPersistentSettingsPayload(input = {}, options = {}) { } payload.cloudflareTempEmailDomains = domains; } + if (payload.ipProxyServiceProfiles) { + const selectedService = normalizeIpProxyProviderValue( + payload.ipProxyService || PERSISTED_SETTING_DEFAULTS.ipProxyService + ); + const normalizedProfiles = normalizeIpProxyServiceProfiles(payload.ipProxyServiceProfiles, { + ...PERSISTED_SETTING_DEFAULTS, + ...payload, + }); + payload.ipProxyServiceProfiles = normalizedProfiles; + const activeProfile = normalizedProfiles[selectedService] + || buildIpProxyServiceProfileFromState({ + ...PERSISTED_SETTING_DEFAULTS, + ...payload, + }); + payload.ipProxyService = selectedService; + payload.ipProxyMode = normalizeIpProxyMode(activeProfile?.mode || payload.ipProxyMode); + payload.ipProxyApiUrl = String(activeProfile?.apiUrl || payload.ipProxyApiUrl || '').trim(); + payload.ipProxyAccountList = normalizeIpProxyAccountList(activeProfile?.accountList || payload.ipProxyAccountList || ''); + payload.ipProxyAccountSessionPrefix = normalizeIpProxyAccountSessionPrefix(activeProfile?.accountSessionPrefix || payload.ipProxyAccountSessionPrefix || ''); + payload.ipProxyAccountLifeMinutes = normalizeIpProxyAccountLifeMinutes(activeProfile?.accountLifeMinutes || payload.ipProxyAccountLifeMinutes || ''); + payload.ipProxyPoolTargetCount = normalizeIpProxyPoolTargetCount(activeProfile?.poolTargetCount || payload.ipProxyPoolTargetCount || '', 20); + payload.ipProxyHost = String(activeProfile?.host || payload.ipProxyHost || '').trim(); + payload.ipProxyPort = String(normalizeIpProxyPort(activeProfile?.port || payload.ipProxyPort || '') || ''); + payload.ipProxyProtocol = normalizeIpProxyProtocol(activeProfile?.protocol || payload.ipProxyProtocol); + payload.ipProxyUsername = String(activeProfile?.username || payload.ipProxyUsername || '').trim(); + payload.ipProxyPassword = String(activeProfile?.password || payload.ipProxyPassword || ''); + payload.ipProxyRegion = String(activeProfile?.region || payload.ipProxyRegion || '').trim(); + } return payload; } @@ -6557,23 +6720,48 @@ const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([ ]); function waitForStepComplete(step, timeoutMs = 120000) { - return new Promise((resolve, reject) => { - throwIfStopped(); - if (stepWaiters.has(step)) { - console.warn(LOG_PREFIX, `[waitForStepComplete] replacing existing waiter for step ${step}`); - } - console.log(LOG_PREFIX, `[waitForStepComplete] register step ${step}, timeout=${timeoutMs}ms`); + throwIfStopped(); + const normalizedStep = Number(step); + const existingWaiter = stepWaiters.get(normalizedStep); + if (existingWaiter?.promise) { + console.log(LOG_PREFIX, `[waitForStepComplete] reuse existing waiter for step ${normalizedStep}`); + return existingWaiter.promise; + } + + console.log(LOG_PREFIX, `[waitForStepComplete] register step ${normalizedStep}, timeout=${timeoutMs}ms`); + const waiter = { + promise: null, + resolve: null, + reject: null, + }; + + waiter.promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { - stepWaiters.delete(step); - console.warn(LOG_PREFIX, `[waitForStepComplete] timeout for step ${step} after ${timeoutMs}ms`); - reject(new Error(`步骤 ${step} 等待超时(>${timeoutMs / 1000} 秒)`)); + if (stepWaiters.get(normalizedStep) === waiter) { + stepWaiters.delete(normalizedStep); + } + console.warn(LOG_PREFIX, `[waitForStepComplete] timeout for step ${normalizedStep} after ${timeoutMs}ms`); + reject(new Error(`步骤 ${normalizedStep} 等待超时(>${timeoutMs / 1000} 秒)`)); }, timeoutMs); - stepWaiters.set(step, { - resolve: (data) => { clearTimeout(timer); stepWaiters.delete(step); resolve(data); }, - reject: (err) => { clearTimeout(timer); stepWaiters.delete(step); reject(err); }, - }); + waiter.resolve = (data) => { + clearTimeout(timer); + if (stepWaiters.get(normalizedStep) === waiter) { + stepWaiters.delete(normalizedStep); + } + resolve(data); + }; + waiter.reject = (err) => { + clearTimeout(timer); + if (stepWaiters.get(normalizedStep) === waiter) { + stepWaiters.delete(normalizedStep); + } + reject(err); + }; }); + + stepWaiters.set(normalizedStep, waiter); + return waiter.promise; } function getStepExecutionKeyForState(step, state = {}) { @@ -7290,6 +7478,85 @@ async function deleteAndBroadcastAccountRunHistoryRecords(recordIds = [], stateO return result; } +function resolveIpProxyCandidateCountForAutoSwitch(state = {}, mode = 'account', provider = DEFAULT_IP_PROXY_SERVICE) { + const normalizedMode = typeof normalizeIpProxyMode === 'function' + ? normalizeIpProxyMode(mode) + : String(mode || 'account').trim().toLowerCase(); + const normalizedProvider = typeof normalizeIpProxyProviderValue === 'function' + ? normalizeIpProxyProviderValue(provider) + : String(provider || DEFAULT_IP_PROXY_SERVICE).trim().toLowerCase(); + if (normalizedMode === 'account' && typeof getAccountModeProxyPoolFromState === 'function') { + const pool = getAccountModeProxyPoolFromState(state, normalizedProvider); + return Array.isArray(pool) ? pool.length : 0; + } + if (typeof getIpProxyRuntimeSnapshot === 'function') { + const runtime = getIpProxyRuntimeSnapshot(state, normalizedMode, normalizedProvider); + return Array.isArray(runtime?.pool) ? runtime.pool.length : 0; + } + return 0; +} + +async function maybeSwitchIpProxyAfterAutoRunRoundSuccess(payload = {}) { + if (typeof switchIpProxy !== 'function') { + return null; + } + const successfulRuns = Number(payload?.successfulRuns) || 0; + if (successfulRuns <= 0) { + return null; + } + + const state = await getState(); + if (!state?.ipProxyEnabled) { + return null; + } + + const mode = typeof normalizeIpProxyMode === 'function' + ? normalizeIpProxyMode(state?.ipProxyMode) + : String(state?.ipProxyMode || 'account').trim().toLowerCase(); + const provider = typeof normalizeIpProxyProviderValue === 'function' + ? normalizeIpProxyProviderValue(state?.ipProxyService) + : String(state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE).trim().toLowerCase(); + const threshold = typeof resolveIpProxyAutoSwitchThreshold === 'function' + ? resolveIpProxyAutoSwitchThreshold(state) + : Math.max(1, Math.min(500, Number(state?.ipProxyPoolTargetCount) || 20)); + if (successfulRuns % threshold !== 0) { + return null; + } + + const candidateCount = resolveIpProxyCandidateCountForAutoSwitch(state, mode, provider); + if (candidateCount <= 1) { + await addLog( + `任务切换阈值命中(成功 ${successfulRuns} 轮 / 阈值 ${threshold}),但当前仅 ${candidateCount} 条可切换代理,已跳过自动切换。`, + 'info' + ); + return { + skipped: true, + reason: 'insufficient_candidates', + candidateCount, + threshold, + successfulRuns, + }; + } + + const switchResult = await switchIpProxy('next', { + mode, + state, + forceRefresh: mode === 'api', + maxItems: typeof resolveIpProxyPoolTargetCountForMode === 'function' + ? resolveIpProxyPoolTargetCountForMode(state, mode) + : undefined, + }); + const display = String(switchResult?.display || '').trim(); + const routingApplied = Boolean(switchResult?.proxyRouting?.applied); + await addLog( + routingApplied + ? `任务切换阈值命中(成功 ${successfulRuns} 轮 / 阈值 ${threshold}),已自动切换代理:${display || '已切换到下一条'}。` + : `任务切换阈值命中(成功 ${successfulRuns} 轮 / 阈值 ${threshold}),已尝试自动切换代理,但连通性仍异常。`, + routingApplied ? 'ok' : 'warn' + ); + return switchResult; +} + const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoRunController({ addLog, appendAccountRunRecord: (...args) => appendAndBroadcastAccountRunRecord(...args), @@ -7316,6 +7583,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR isStopError, launchAutoRunTimerPlan, normalizeAutoRunFallbackThreadIntervalMinutes, + onAutoRunRoundSuccess: (payload = {}) => maybeSwitchIpProxyAfterAutoRunRoundSuccess(payload), persistAutoRunTimerPlan, resetState, runAutoSequenceFromStep: (...args) => runAutoSequenceFromStep(...args), @@ -8118,6 +8386,7 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ addLog, chrome, CLOUDFLARE_TEMP_EMAIL_PROVIDER, + completeStepFromBackground, confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, ensureMail2925MailboxSession, ensureIcloudMailSession: ensureIcloudMailSessionForVerification, @@ -8234,6 +8503,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter buildLuckmailSessionSettingsPayload, buildPersistentSettingsPayload, broadcastDataUpdate, + applyIpProxySettingsFromState, cancelScheduledAutoRun, checkIcloudSession, clearAccountRunHistory: (...args) => clearAndBroadcastAccountRunHistory(...args), @@ -8290,6 +8560,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter launchAutoRunTimerPlan, listIcloudAliases, listLuckmailPurchasesForManagement, + refreshIpProxyPool, getCurrentMail2925Account, normalizeHotmailAccounts, normalizeMail2925Accounts, @@ -8301,10 +8572,13 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter patchMail2925Account, registerTab, requestStop, + probeIpProxyExit, resetState, resumeAutoRun, scheduleAutoRun, selectLuckmailPurchase, + switchIpProxy, + changeIpProxyExit, setCurrentHotmailAccount, setCurrentMail2925Account, setContributionMode, @@ -8819,6 +9093,38 @@ function isAddPhoneAuthState(authState = {}) { } async function getPostStep6AutoRestartDecision(step, error) { + const resolveStepKey = (stepId, state) => { + if (typeof getStepExecutionKeyForState === 'function') { + return getStepExecutionKeyForState(stepId, state); + } + return String( + typeof getStepDefinitionForState === 'function' + ? (getStepDefinitionForState(stepId, state)?.key || '') + : '' + ).trim(); + }; + const findStepIdByKeyForState = (targetKey, state = {}) => { + const normalizedKey = String(targetKey || '').trim(); + if (!normalizedKey) { + return null; + } + const stepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : []; + for (const stepId of stepIds) { + if (resolveStepKey(stepId, state) === normalizedKey) { + return Number(stepId); + } + } + return null; + }; + const isPlatformVerifyTransientRetryError = (errorMessage = '') => { + const normalizedMessage = String(errorMessage || ''); + const mentionsTokenExchange = /auth\.openai\.com\/oauth\/token/i.test(normalizedMessage); + const hasTransientNetworkSignal = /connect:\s*connection refused|failed to fetch|i\/o timeout|context deadline exceeded|eof|connection reset by peer/i.test(normalizedMessage); + return mentionsTokenExchange && hasTransientNetworkSignal; + }; + const normalizedStep = Number(step); const errorMessage = getErrorMessage(error); const shouldForceRestartFromStep7 = /restart step 7 with a new number/i.test(errorMessage); @@ -8829,6 +9135,14 @@ async function getPostStep6AutoRestartDecision(step, error) { const lastStepId = typeof getLastStepIdForState === 'function' ? getLastStepIdForState(latestState) : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10); + const currentStepKey = resolveStepKey(normalizedStep, latestState); + const confirmOauthStep = findStepIdByKeyForState('confirm-oauth', latestState); + const shouldRetryFromConfirmStep = currentStepKey === 'platform-verify' + && Number.isFinite(confirmOauthStep) + && confirmOauthStep > 0 + && confirmOauthStep < normalizedStep + && isPlatformVerifyTransientRetryError(errorMessage); + const restartAnchorStep = shouldRetryFromConfirmStep ? confirmOauthStep : authChainStartStep; if (!Number.isFinite(normalizedStep) || normalizedStep < authChainStartStep || normalizedStep > lastStepId) { return { shouldRestart: false, @@ -8865,7 +9179,7 @@ async function getPostStep6AutoRestartDecision(step, error) { let authState = null; try { authState = await getLoginAuthStateFromContent({ - logMessage: `步骤 ${normalizedStep}:正在确认当前认证页状态,以决定是否回到步骤 ${authChainStartStep} 重开...`, + logMessage: `步骤 ${normalizedStep}:正在确认当前认证页状态,以决定是否回到步骤 ${restartAnchorStep} 重开...`, }); } catch (inspectError) { console.warn(LOG_PREFIX, '[AutoRun] failed to inspect login auth state after post-step6 error', { @@ -8890,7 +9204,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: true, blockedByAddPhone: false, forcedByPhoneVerificationTimeout: false, - restartStep: authChainStartStep, + restartStep: restartAnchorStep, errorMessage, authState, }; @@ -8923,8 +9237,12 @@ async function getLoginAuthStateFromContent(options = {}) { async function ensureStep8VerificationPageReady(options = {}) { const visibleStep = Number(options.visibleStep) || 8; const authLoginStep = Number(options.authLoginStep) || (visibleStep >= 11 ? 10 : 7); - const pageState = await getLoginAuthStateFromContent(options); - if (pageState.state === 'verification_page') { + const inspectState = async (overrides = {}) => getLoginAuthStateFromContent({ + ...options, + ...overrides, + }); + let pageState = await inspectState(); + if (pageState.state === 'verification_page' || pageState.state === 'oauth_consent_page') { return pageState; } @@ -8933,11 +9251,79 @@ async function ensureStep8VerificationPageReady(options = {}) { } if (pageState.state === 'login_timeout_error_page') { + let recovered = false; + try { + const recoverPayload = { + flow: 'login', + logLabel: `步骤 ${visibleStep}:检测到登录超时报错,正在点击“重试”恢复当前页面`, + step: visibleStep, + timeoutMs: 12000, + }; + const recoverMessage = { + type: 'RECOVER_AUTH_RETRY_PAGE', + source: 'background', + payload: recoverPayload, + }; + let recoverResult = null; + const recoverTimeoutMs = 15000; + if (typeof sendToContentScriptResilient === 'function') { + recoverResult = await sendToContentScriptResilient( + 'signup-page', + recoverMessage, + { + timeoutMs: recoverTimeoutMs, + responseTimeoutMs: recoverTimeoutMs, + retryDelayMs: 700, + logMessage: `步骤 ${visibleStep}:认证页进入重试/超时报错状态,正在尝试点击“重试”恢复...`, + } + ); + } else if (typeof sendToContentScript === 'function') { + recoverResult = await sendToContentScript('signup-page', recoverMessage, { + responseTimeoutMs: recoverTimeoutMs, + }); + } + + if (recoverResult?.error) { + throw new Error(recoverResult.error); + } + recovered = Boolean(recoverResult?.recovered || Number(recoverResult?.clickCount) > 0); + if (recovered && typeof addLog === 'function') { + await addLog(`步骤 ${visibleStep}:认证页已点击“重试”,正在重新确认验证码页状态...`, 'warn'); + } + } catch (recoverError) { + const recoverMessage = getErrorMessage(recoverError); + if (/^CF_SECURITY_BLOCKED::/i.test(recoverMessage)) { + throw recoverError; + } + if (typeof addLog === 'function') { + await addLog(`步骤 ${visibleStep}:认证页“重试”恢复失败:${recoverMessage}`, 'warn'); + } + } + + if (recovered) { + pageState = await inspectState({ + timeoutMs: 10000, + responseTimeoutMs: 10000, + retryDelayMs: 500, + logMessage: `步骤 ${visibleStep}:认证页恢复后,正在确认验证码页是否可继续...`, + }); + if (pageState.state === 'verification_page' || pageState.state === 'oauth_consent_page') { + return pageState; + } + if (pageState.maxCheckAttemptsBlocked) { + throw new Error(`${CLOUDFLARE_SECURITY_BLOCK_ERROR_PREFIX}${CLOUDFLARE_SECURITY_BLOCK_USER_MESSAGE}`); + } + if (pageState.state === 'add_phone_page' || pageState.state === 'phone_verification_page') { + const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; + throw new Error(`步骤 ${visibleStep}:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); + } + } + const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; throw new Error(`STEP8_RESTART_STEP7::步骤 ${visibleStep}:当前认证页进入登录超时报错页,请回到步骤 ${authLoginStep} 重新开始。${urlPart}`.trim()); } - if (pageState.state === 'add_phone_page') { + if (pageState.state === 'add_phone_page' || pageState.state === 'phone_verification_page') { const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; throw new Error(`步骤 ${visibleStep}:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); } @@ -9144,7 +9530,15 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS) throw new Error('步骤 9:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'); } if (pageState?.retryPage) { - throw new Error(`步骤 9:当前认证页已进入重试页,当前流程将直接报错。URL: ${pageState.url || 'unknown'}`); + const retryUrl = String(pageState?.url || '').trim(); + const consentLikeRetry = Boolean( + pageState?.consentReady + || pageState?.consentPage + || /\/sign-in-with-chatgpt\/[^/?#]+\/consent(?:[/?#]|$)/i.test(retryUrl) + ); + if (!consentLikeRetry) { + throw new Error(`步骤 9:当前认证页已进入重试页,当前流程将直接报错。URL: ${pageState.url || 'unknown'}`); + } } if (pageState?.consentReady) { return pageState; @@ -9311,7 +9705,15 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI throw new Error('步骤 9:点击“继续”后页面跳到了手机号页面,当前流程无法继续自动授权。'); } if (pageState?.retryPage) { - throw new Error(`步骤 9:点击“继续”后页面进入认证页重试页,当前流程将直接报错。URL: ${pageState.url || baselineUrl || 'unknown'}`); + const retryUrl = String(pageState?.url || baselineUrl || '').trim(); + const consentLikeRetry = Boolean( + pageState?.consentReady + || pageState?.consentPage + || /\/sign-in-with-chatgpt\/[^/?#]+\/consent(?:[/?#]|$)/i.test(retryUrl) + ); + if (!consentLikeRetry) { + throw new Error(`步骤 9:点击“继续”后页面进入认证页重试页,当前流程将直接报错。URL: ${pageState.url || baselineUrl || 'unknown'}`); + } } if (pageState === null) { if (!recovered) { @@ -9488,14 +9890,38 @@ chrome.runtime.onStartup.addListener(() => { restoreAutoRunTimerIfNeeded().catch((err) => { console.error(LOG_PREFIX, 'Failed to restore auto run timer on startup:', err); }); + if (IP_PROXY_INIT_AUTO_APPLY) { + ensureIpProxySettingsAppliedFromCurrentState({ + skipExitProbe: !IP_PROXY_INIT_ENABLE_EXIT_PROBE, + suppressAuthRebind: IP_PROXY_INIT_SUPPRESS_AUTH_REBIND, + }).catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore IP proxy settings on startup:', err); + }); + } }); chrome.runtime.onInstalled.addListener(() => { restoreAutoRunTimerIfNeeded().catch((err) => { console.error(LOG_PREFIX, 'Failed to restore auto run timer on install/update:', err); }); + if (IP_PROXY_INIT_AUTO_APPLY) { + ensureIpProxySettingsAppliedFromCurrentState({ + skipExitProbe: !IP_PROXY_INIT_ENABLE_EXIT_PROBE, + suppressAuthRebind: IP_PROXY_INIT_SUPPRESS_AUTH_REBIND, + }).catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore IP proxy settings on install/update:', err); + }); + } }); restoreAutoRunTimerIfNeeded().catch((err) => { console.error(LOG_PREFIX, 'Failed to restore auto run timer:', err); }); +if (IP_PROXY_INIT_AUTO_APPLY) { + ensureIpProxySettingsAppliedFromCurrentState({ + skipExitProbe: !IP_PROXY_INIT_ENABLE_EXIT_PROBE, + suppressAuthRebind: IP_PROXY_INIT_SUPPRESS_AUTH_REBIND, + }).catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore IP proxy settings:', err); + }); +} diff --git a/background/ip-proxy-core.js b/background/ip-proxy-core.js new file mode 100644 index 0000000..5f5c9d8 --- /dev/null +++ b/background/ip-proxy-core.js @@ -0,0 +1,3607 @@ +// background/ip-proxy-core.js — IP代理核心(解析/应用/切换/探测) +let ipProxyAuthListenerInstalled = false; +let ipProxyErrorListenerInstalled = false; +let currentIpProxyAuthEntry = null; +let ipProxyExitDetectionToken = 0; +let lastAppliedIpProxyEntrySignature = ''; +let ipProxyAuthHostVariantToggle = false; +const ipProxyHostResolveCache = new Map(); +const IP_PROXY_HOST_RESOLVE_CACHE_TTL_MS = 60 * 1000; +const ipProxyHostResolveCursor = new Map(); +let lastAppliedIpProxyAuthSnapshot = { + host: '', + port: 0, + username: '', + password: '', +}; +let ipProxyAuthDiagnostics = { + challengeCount: 0, + providedCount: 0, + lastIsProxy: null, + lastStatusCode: 0, + lastHost: '', + lastPort: 0, + lastAt: 0, +}; +let ipProxyLastRuntimeError = { + error: '', + details: '', + fatal: false, + at: 0, +}; +const IP_PROXY_EXIT_PROBE_ENDPOINTS = [ + 'http://ip-api.com/json?lang=en', + 'http://ipinfo.io/json', + 'https://ipinfo.io/json', + 'http://myip.ipip.net', + 'https://chatgpt.com/cdn-cgi/trace', + 'https://ipwho.is/', + 'https://ipapi.co/json/', + 'https://api.ipify.org?format=json', + 'https://api64.ipify.org?format=json', + 'https://httpbin.org/ip', + 'https://checkip.amazonaws.com', + 'https://ident.me', +]; +const IP_PROXY_EXIT_PROBE_ENDPOINTS_711_STICKY = [ + // 与 curl 口径对齐,保持单端点,避免多站点探测引入额外波动与耗时。 + 'https://ipinfo.io/json', +]; +const IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS = 4; +const IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS = 3500; +const IP_PROXY_PAGE_CONTEXT_PROBE_URL = 'https://example.com/'; +const IP_PROXY_PAGE_CONTEXT_BASELINE_URL = 'https://ifconfig.co/json'; +const IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS = 8000; +const IP_PROXY_PAGE_CONTEXT_BASELINE_TIMEOUT_MS = 6000; +const IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS = 8; +const IP_PROXY_GUARD_BLOCK_RULE_ID = 10991; +const IP_PROXY_GUARD_REGEX = '^https?:\\/\\/([^\\/]+\\.)?(chatgpt\\.com|openai\\.com)(\\/|$)'; + +function normalizeIpProxyProviderValue(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + const enabledValues = Array.isArray(globalThis.IP_PROXY_ENABLED_SERVICE_VALUES) + ? globalThis.IP_PROXY_ENABLED_SERVICE_VALUES + : []; + if (enabledValues.includes(normalized)) { + return normalized; + } + if (IP_PROXY_SERVICE_VALUES.includes(normalized)) { + return DEFAULT_IP_PROXY_SERVICE; + } + return DEFAULT_IP_PROXY_SERVICE; +} + +function normalizeProxyHostForCompare(value = '') { + return String(value || '').trim().toLowerCase().replace(/\.$/, ''); +} + +function stripProxyHostTrailingDot(value = '') { + return String(value || '').trim().replace(/\.$/, ''); +} + +function isIpv4LikeHost(value = '') { + const text = String(value || '').trim(); + if (!text) { + return false; + } + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(text); +} + +function isIpv6LikeHost(value = '') { + const text = String(value || '').trim(); + if (!text) { + return false; + } + const compact = text.replace(/^\[/, '').replace(/\]$/, ''); + return compact.includes(':'); +} + +function canUseProxyHostVariant(value = '') { + const host = stripProxyHostTrailingDot(value); + if (!host) { + return false; + } + if (isIpv4LikeHost(host) || isIpv6LikeHost(host)) { + return false; + } + return /[A-Za-z]/.test(host) && host.includes('.'); +} + +function isValidIpv4Address(value = '') { + const text = String(value || '').trim(); + if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(text)) { + return false; + } + const parts = text.split('.'); + return parts.length === 4 && parts.every((part) => { + const numeric = Number.parseInt(part, 10); + return Number.isInteger(numeric) && numeric >= 0 && numeric <= 255; + }); +} + +function readCachedResolvedProxyHosts(host = '') { + const key = stripProxyHostTrailingDot(host).toLowerCase(); + if (!key) { + return []; + } + const cached = ipProxyHostResolveCache.get(key); + if (!cached || !Array.isArray(cached?.ips) || !cached?.expiresAt) { + return []; + } + if (cached.expiresAt <= Date.now()) { + ipProxyHostResolveCache.delete(key); + return []; + } + return cached.ips; +} + +function writeCachedResolvedProxyHosts(host = '', ips = []) { + const key = stripProxyHostTrailingDot(host).toLowerCase(); + if (!key) { + return; + } + const normalized = Array.from(new Set((Array.isArray(ips) ? ips : []) + .map((item) => String(item || '').trim()) + .filter((item) => isValidIpv4Address(item)))); + if (!normalized.length) { + return; + } + ipProxyHostResolveCache.set(key, { + ips: normalized, + expiresAt: Date.now() + IP_PROXY_HOST_RESOLVE_CACHE_TTL_MS, + }); + if (!ipProxyHostResolveCursor.has(key)) { + ipProxyHostResolveCursor.set(key, 0); + } +} + +function pickNextResolvedProxyHost(host = '', candidates = []) { + const key = stripProxyHostTrailingDot(host).toLowerCase(); + const list = Array.isArray(candidates) + ? candidates.map((item) => String(item || '').trim()).filter((item) => isValidIpv4Address(item)) + : []; + if (!key || !list.length) { + return ''; + } + const cursor = Number(ipProxyHostResolveCursor.get(key) || 0); + const index = Math.abs(cursor) % list.length; + ipProxyHostResolveCursor.set(key, cursor + 1); + return String(list[index] || '').trim(); +} + +async function resolveProxyHostIpv4Candidates(host = '', timeoutMs = 3500) { + const normalizedHost = stripProxyHostTrailingDot(host); + if (!canUseProxyHostVariant(normalizedHost)) { + return []; + } + const cached = readCachedResolvedProxyHosts(normalizedHost); + if (cached.length) { + return cached; + } + const endpoint = `https://dns.google/resolve?name=${encodeURIComponent(normalizedHost)}&type=A`; + const response = await fetchWithTimeout(endpoint, { + method: 'GET', + cache: 'no-store', + headers: { + Accept: 'application/dns-json, application/json, text/plain, */*', + }, + }, timeoutMs); + if (!response.ok) { + return []; + } + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== 'object') { + return []; + } + const answers = Array.isArray(payload?.Answer) ? payload.Answer : []; + const ips = answers + .filter((item) => Number(item?.type) === 1) + .map((item) => String(item?.data || '').trim()) + .filter((ip) => isValidIpv4Address(ip)); + const uniqueIps = Array.from(new Set(ips)); + if (!uniqueIps.length) { + return []; + } + writeCachedResolvedProxyHosts(normalizedHost, uniqueIps); + return uniqueIps; +} + +async function maybeResolveProxyHostVariantForAuthSwitch(entry = {}, options = {}) { + const force = Boolean(options?.force); + if (!force) { + return entry; + } + const provider = normalizeIpProxyProviderValue(entry?.provider || DEFAULT_IP_PROXY_SERVICE); + const allow711ResolvedIp = Boolean(options?.allow711ResolvedIp); + // 711 默认仍保持域名路由;仅在“多账号列表切换”场景下允许解析 IP 变体, + // 用于强制打断同 host:port 连接复用,提升触发 407 挑战的概率。 + if (provider === '711proxy' && !allow711ResolvedIp) { + return entry; + } + const protocol = normalizeIpProxyProtocol(entry?.protocol || DEFAULT_IP_PROXY_PROTOCOL); + if (protocol !== 'http') { + return entry; + } + if (!String(entry?.username || '').trim()) { + return entry; + } + const host = stripProxyHostTrailingDot(entry?.host || ''); + if (!canUseProxyHostVariant(host)) { + return entry; + } + const candidates = await resolveProxyHostIpv4Candidates(host, Number(options?.timeoutMs) || 3500).catch(() => []); + if (!candidates.length) { + return entry; + } + const selectedHost = pickNextResolvedProxyHost(host, candidates); + if (!selectedHost || !isValidIpv4Address(selectedHost)) { + return entry; + } + return { + ...entry, + host: selectedHost, + }; +} + +function buildHostVariantForProxy(value = '', useTrailingDot = false) { + const host = stripProxyHostTrailingDot(value); + if (!canUseProxyHostVariant(host)) { + return host; + } + return useTrailingDot ? `${host}.` : host; +} + +function shouldForceProxyConnectionDrainForEntry(entry = {}) { + const nextHost = normalizeProxyHostForCompare(entry?.host || ''); + const nextPort = normalizeIpProxyPort(entry?.port); + const nextUser = String(entry?.username || '').trim(); + const nextPass = String(entry?.password || ''); + if (!nextHost || !nextPort || !nextUser) { + return false; + } + const previousHost = normalizeProxyHostForCompare(lastAppliedIpProxyAuthSnapshot?.host || ''); + const previousPort = normalizeIpProxyPort(lastAppliedIpProxyAuthSnapshot?.port); + if (!previousHost || !previousPort) { + return false; + } + const sameEndpoint = nextHost === previousHost && nextPort === previousPort; + if (!sameEndpoint) { + return false; + } + const previousUser = String(lastAppliedIpProxyAuthSnapshot?.username || '').trim(); + const previousPass = String(lastAppliedIpProxyAuthSnapshot?.password || ''); + return nextUser !== previousUser || nextPass !== previousPass; +} + +function buildEffectiveProxyEntryForApply(entry = {}, options = {}) { + const forceRotateVariant = Boolean(options?.forceRotateVariant); + const next = { ...(entry || {}) }; + const provider = normalizeIpProxyProviderValue(next?.provider || DEFAULT_IP_PROXY_SERVICE); + const originalHost = String(next?.host || '').trim(); + if (provider === '711proxy') { + const allow711HostVariant = Boolean(options?.allow711HostVariant) && forceRotateVariant; + if (allow711HostVariant && canUseProxyHostVariant(originalHost)) { + ipProxyAuthHostVariantToggle = !ipProxyAuthHostVariantToggle; + next.host = buildHostVariantForProxy(originalHost, ipProxyAuthHostVariantToggle); + return next; + } + // 单账号场景下保持 host 稳定,避免不必要的链路扰动。 + next.host = stripProxyHostTrailingDot(originalHost); + return next; + } + const canVariant = canUseProxyHostVariant(originalHost); + if (!canVariant) { + next.host = stripProxyHostTrailingDot(originalHost); + return next; + } + if (forceRotateVariant) { + ipProxyAuthHostVariantToggle = !ipProxyAuthHostVariantToggle; + } + next.host = buildHostVariantForProxy(originalHost, ipProxyAuthHostVariantToggle); + return next; +} + +function resetIpProxyAuthDiagnostics() { + ipProxyAuthDiagnostics = { + challengeCount: 0, + providedCount: 0, + lastIsProxy: null, + lastStatusCode: 0, + lastHost: '', + lastPort: 0, + lastAt: 0, + }; +} + +function recordIpProxyAuthChallenge(details = {}, provided = false) { + const host = String(details?.challenger?.host || '').trim(); + const port = Number.parseInt(String(details?.challenger?.port || ''), 10) || 0; + const statusCode = Number.parseInt(String(details?.statusCode || ''), 10) || 0; + const isProxy = details?.isProxy === true; + ipProxyAuthDiagnostics.challengeCount += 1; + if (provided) { + ipProxyAuthDiagnostics.providedCount += 1; + } + ipProxyAuthDiagnostics.lastIsProxy = isProxy; + ipProxyAuthDiagnostics.lastStatusCode = statusCode; + ipProxyAuthDiagnostics.lastHost = host; + ipProxyAuthDiagnostics.lastPort = port; + ipProxyAuthDiagnostics.lastAt = Date.now(); +} + +function getIpProxyAuthDiagnosticsSummary() { + const lastHost = String(ipProxyAuthDiagnostics?.lastHost || '').trim(); + const lastPort = Number(ipProxyAuthDiagnostics?.lastPort || 0); + const hostPort = lastHost + ? `${lastHost}${lastPort > 0 ? `:${lastPort}` : ''}` + : 'unknown'; + const isProxyText = ipProxyAuthDiagnostics?.lastIsProxy === null + ? 'n/a' + : (ipProxyAuthDiagnostics.lastIsProxy ? 'true' : 'false'); + return `auth(challenge=${Number(ipProxyAuthDiagnostics?.challengeCount || 0)},provided=${Number(ipProxyAuthDiagnostics?.providedCount || 0)},isProxy=${isProxyText},status=${Number(ipProxyAuthDiagnostics?.lastStatusCode || 0)},host=${hostPort})`; +} + +function appendIpProxyAuthDiagnosticsToErrors(errors = []) { + if (!Array.isArray(errors)) { + return; + } + errors.push(`probe:${getIpProxyAuthDiagnosticsSummary()}`); +} + +function parseIpProxyAuthDiagnosticsSummary(summary = '') { + const text = String(summary || '').trim(); + if (!text) { + return null; + } + const challenge = Number.parseInt(String(text.match(/challenge=(\d+)/i)?.[1] || ''), 10) || 0; + const provided = Number.parseInt(String(text.match(/provided=(\d+)/i)?.[1] || ''), 10) || 0; + return { challenge, provided }; +} + +function resetIpProxyRuntimeErrorDiagnostics() { + ipProxyLastRuntimeError = { + error: '', + details: '', + fatal: false, + at: 0, + }; +} + +function getIpProxyRuntimeErrorSummary() { + const error = String(ipProxyLastRuntimeError?.error || '').trim(); + const details = String(ipProxyLastRuntimeError?.details || '').trim(); + const fatal = Boolean(ipProxyLastRuntimeError?.fatal); + if (!error && !details) { + return ''; + } + return `proxy_error(error=${error || 'unknown'},fatal=${fatal ? 1 : 0},details=${details || 'n/a'})`; +} + +function appendIpProxyRuntimeErrorDiagnosticsToErrors(errors = [], maxAgeMs = 30000) { + if (!Array.isArray(errors)) { + return; + } + const summary = getIpProxyRuntimeErrorSummary(); + if (!summary) { + return; + } + const ageMs = Date.now() - Number(ipProxyLastRuntimeError?.at || 0); + if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > Math.max(1000, Number(maxAgeMs) || 30000)) { + return; + } + errors.push(`probe:${summary}`); +} + +function buildProbeDiagnosticsSummary(errors = [], maxItems = 4) { + if (!Array.isArray(errors) || !errors.length) { + return ''; + } + const normalized = errors + .map((item) => String(item || '').trim()) + .filter(Boolean); + if (!normalized.length) { + return ''; + } + const authItem = normalized.find((item) => /probe:auth\(/i.test(item)) || ''; + const proxyErrorItem = normalized.find((item) => /probe:proxy_error\(/i.test(item)) || ''; + const navigationItem = normalized.find((item) => /probe:page_context:navigation_error:/i.test(item)) || ''; + const pageContextItem = normalized.find((item) => /probe:page_context:/i.test(item)) || ''; + const slowRetryItem = normalized.find((item) => /probe:bg:slow_retry:/i.test(item)) || ''; + const retryItem = normalized.find((item) => /probe:bg:retry:/i.test(item)) || ''; + const abortFallbackItem = normalized.find((item) => /probe:bg:abort_storm_page_fallback/i.test(item)) || ''; + const headLimit = Math.max(1, Number(maxItems) || 4); + const headItems = normalized.slice(0, headLimit); + const mustKeepItems = [ + authItem, + proxyErrorItem, + navigationItem, + pageContextItem, + slowRetryItem, + retryItem, + abortFallbackItem, + ].filter(Boolean); + for (const mustKeepItem of mustKeepItems) { + if (headItems.includes(mustKeepItem)) { + continue; + } + if (headItems.length >= headLimit) { + headItems[headItems.length - 1] = mustKeepItem; + } else { + headItems.push(mustKeepItem); + } + } + return headItems.join(' | '); +} + +function normalizeIpProxyMode(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return IP_PROXY_MODE_VALUES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_MODE; +} + +function normalizeIpProxyProtocol(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return IP_PROXY_PROTOCOL_VALUES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_PROTOCOL; +} + +function normalizeIpProxyPort(value = '') { + const numeric = Number.parseInt(String(value || '').trim(), 10); + if (!Number.isInteger(numeric) || numeric <= 0 || numeric > 65535) { + return 0; + } + return numeric; +} + +function normalizeIpProxyCurrentIndex(value, fallback = 0) { + const numeric = Number.parseInt(String(value ?? ''), 10); + if (!Number.isInteger(numeric) || numeric < 0) { + return Math.max(0, Number(fallback) || 0); + } + return numeric; +} + +function normalizeIpProxyPoolTargetCount(value = '', fallback = 20) { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return String(Math.max(1, Math.min(500, Number(fallback) || 20))); + } + const numeric = Number.parseInt(rawValue, 10); + if (!Number.isInteger(numeric)) { + return String(Math.max(1, Math.min(500, Number(fallback) || 20))); + } + return String(Math.max(1, Math.min(500, numeric))); +} + +function normalizeIpProxyAccountLifeMinutes(value = '') { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + const numeric = Number.parseInt(raw, 10); + if (!Number.isInteger(numeric)) return ''; + return String(Math.max(1, Math.min(1440, numeric))); +} + +function normalizeIpProxyAccountSessionPrefix(value = '') { + return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32); +} + +function normalizeIpProxyAccountList(value = '') { + return String(value || '') + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => { + if (!line) { + return false; + } + // 允许用注释快速停用账号列表行,避免“看似注释、实际仍生效”。 + if (/^(?:#|\/\/|;)/.test(line)) { + return false; + } + return true; + }) + .join('\n'); +} + +function inferRegionFromProxyUsername(username = '') { + const text = String(username || '').trim(); + if (!text) { + return ''; + } + const match = text.match(/(?:^|[-_])(?:region|area|country)[-_:]?([A-Za-z]{2})\b/i); + if (!match) { + return ''; + } + return String(match[1] || '').trim().toUpperCase(); +} + +function inferRegionFromProxyHost(host = '') { + const text = String(host || '').trim().toLowerCase().replace(/\.$/, ''); + if (!text || !text.includes('.')) { + return ''; + } + const firstLabel = String(text.split('.')[0] || '').trim(); + if (!/^[a-z]{2}$/.test(firstLabel)) { + return ''; + } + return firstLabel.toUpperCase(); +} + +function shouldApplyConfiguredRegionFallbackToEntry(entry = {}, context = {}) { + const provider = normalizeIpProxyProviderValue( + context?.provider || entry?.provider || DEFAULT_IP_PROXY_SERVICE + ); + const configuredRegion = String(context?.configuredRegion || '').trim(); + if (!configuredRegion) { + return false; + } + if (Boolean(context?.hasAccountList)) { + return false; + } + const entryRegion = String(entry?.region || '').trim(); + if (entryRegion) { + return false; + } + if (provider !== '711proxy') { + return true; + } + const usernameRegion = inferRegionFromProxyUsername(entry?.username || ''); + if (usernameRegion) { + return true; + } + const hostRegion = inferRegionFromProxyHost(entry?.host || ''); + if (hostRegion) { + return true; + } + // 711 固定账号在 zone-custom 混播场景下,不使用旧的配置地区兜底,避免误报“卡在 AF”。 + return false; +} + +function resolveIpProxyAccountEntrySource(state = {}, mode = normalizeIpProxyMode(state?.ipProxyMode)) { + const normalizedMode = normalizeIpProxyMode(mode); + if (normalizedMode !== 'account') { + return 'non_account'; + } + return hasConfiguredAccountListEntries(state) ? 'account_list' : 'fixed_account'; +} + +function isIpProxyAccountListEnabled() { + return Boolean(typeof IP_PROXY_ACCOUNT_LIST_ENABLED === 'undefined' + ? true + : IP_PROXY_ACCOUNT_LIST_ENABLED); +} + +function normalizeIpProxyServiceProfile(rawValue = {}) { + const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) + ? rawValue + : {}; + return { + mode: normalizeIpProxyMode(raw.mode), + apiUrl: String(raw.apiUrl || '').trim(), + accountList: normalizeIpProxyAccountList(raw.accountList || ''), + accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''), + accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''), + poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20), + host: String(raw.host || '').trim(), + port: String(normalizeIpProxyPort(raw.port || '') || ''), + protocol: normalizeIpProxyProtocol(raw.protocol), + username: String(raw.username || '').trim(), + password: String(raw.password || ''), + region: String(raw.region || '').trim(), + }; +} + +function buildIpProxyServiceProfileFromState(state = {}) { + return normalizeIpProxyServiceProfile({ + mode: state?.ipProxyMode, + apiUrl: state?.ipProxyApiUrl, + accountList: state?.ipProxyAccountList, + accountSessionPrefix: state?.ipProxyAccountSessionPrefix, + accountLifeMinutes: state?.ipProxyAccountLifeMinutes, + poolTargetCount: state?.ipProxyPoolTargetCount, + host: state?.ipProxyHost, + port: state?.ipProxyPort, + protocol: state?.ipProxyProtocol, + username: state?.ipProxyUsername, + password: state?.ipProxyPassword, + region: state?.ipProxyRegion, + }); +} + +function normalizeIpProxyServiceProfiles(rawValue = {}, fallbackState = {}) { + const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) + ? rawValue + : {}; + const fallbackProfile = buildIpProxyServiceProfileFromState(fallbackState); + const result = {}; + IP_PROXY_SERVICE_VALUES.forEach((service) => { + const serviceRaw = raw[service]; + if (serviceRaw && typeof serviceRaw === 'object' && !Array.isArray(serviceRaw)) { + result[service] = normalizeIpProxyServiceProfile(serviceRaw); + return; + } + result[service] = normalizeIpProxyServiceProfile(fallbackProfile); + }); + return result; +} + +function normalizeIpProxyEntriesFromObjectCandidate(candidate, provider = DEFAULT_IP_PROXY_SERVICE) { + if (!candidate || typeof candidate !== 'object') { + return null; + } + + const directProxyText = String( + candidate.proxy + || candidate.proxy_url + || candidate.proxyUrl + || candidate.proxyAddress + || '' + ).trim(); + if (directProxyText) { + const parsed = parseIpProxyLine(directProxyText, provider); + if (parsed) { + return { + ...parsed, + region: String(candidate.region || candidate.country || candidate.city || parsed.region || '').trim(), + }; + } + } + + const host = String( + candidate.host + || candidate.ip + || candidate.server + || candidate.address + || candidate.proxy_host + || candidate.proxyHost + || '' + ).trim(); + const port = normalizeIpProxyPort( + candidate.port + || candidate.proxy_port + || candidate.proxyPort + || '' + ); + if (!host || !port) { + return null; + } + + const username = String( + candidate.username + || candidate.user + || candidate.account + || candidate.proxy_user + || candidate.proxyUser + || '' + ).trim(); + const password = String( + candidate.password + || candidate.pass + || candidate.pwd + || candidate.proxy_pass + || candidate.proxyPass + || '' + ); + const protocol = normalizeIpProxyProtocol( + candidate.protocol + || candidate.schema + || candidate.scheme + || candidate.type + || '' + ); + const region = String(candidate.region || candidate.country || candidate.city || '').trim(); + return { + host, + port, + username, + password, + protocol, + region, + provider: normalizeIpProxyProviderValue(candidate.provider || provider), + }; +} + +function parseIpProxyLine(line, provider = DEFAULT_IP_PROXY_SERVICE) { + const text = String(line || '').trim(); + if (!text) { + return null; + } + + const schemaMatch = text.match(/^(https?|socks4|socks5):\/\/(.+)$/i); + const protocol = normalizeIpProxyProtocol(schemaMatch ? schemaMatch[1] : ''); + const rawBody = schemaMatch ? schemaMatch[2] : text; + const firstSegment = rawBody.split(/[/?#]/)[0]; + const parts = firstSegment.split(':'); + if (parts.length < 2) { + return null; + } + const host = String(parts[0] || '').trim(); + const port = normalizeIpProxyPort(parts[1]); + if (!host || !port) { + return null; + } + + const username = String(parts[2] || '').trim(); + const password = parts.length >= 4 ? String(parts.slice(3).join(':') || '') : ''; + return { + host, + port, + username, + password, + protocol, + region: '', + provider: normalizeIpProxyProviderValue(provider), + }; +} + +function enumerateContiguousIpv4WithColonCandidates(text = '', startIndex = 0) { + const source = String(text || ''); + const candidates = []; + const sourceLength = source.length; + const isDigitCode = (code) => code >= 48 && code <= 57; + + const readOctet = (offset, maxLen = 3) => { + let end = offset; + while (end < sourceLength && end < offset + maxLen) { + const code = source.charCodeAt(end); + if (!isDigitCode(code)) { + break; + } + end += 1; + } + if (end <= offset) { + return null; + } + const value = Number.parseInt(source.slice(offset, end), 10); + if (!Number.isInteger(value) || value < 0 || value > 255) { + return null; + } + return { value, end }; + }; + + for (let l1 = 1; l1 <= 3; l1 += 1) { + const o1 = readOctet(startIndex, l1); + if (!o1 || source[o1.end] !== '.') continue; + for (let l2 = 1; l2 <= 3; l2 += 1) { + const o2 = readOctet(o1.end + 1, l2); + if (!o2 || source[o2.end] !== '.') continue; + for (let l3 = 1; l3 <= 3; l3 += 1) { + const o3 = readOctet(o2.end + 1, l3); + if (!o3 || source[o3.end] !== '.') continue; + for (let l4 = 1; l4 <= 3; l4 += 1) { + const o4 = readOctet(o3.end + 1, l4); + if (!o4 || source[o4.end] !== ':') continue; + const host = `${o1.value}.${o2.value}.${o3.value}.${o4.value}`; + candidates.push({ + host, + hostEnd: o4.end, + portStart: o4.end + 1, + }); + } + } + } + } + + return candidates; +} + +function parseContiguousProxyStream(rawText = '', provider = DEFAULT_IP_PROXY_SERVICE) { + const source = String(rawText || '').trim(); + if (!source) { + return []; + } + if (!/^\d/.test(source) || !source.includes(':')) { + return []; + } + + const memo = new Map(); + + const solve = (offset = 0, previousHost = '') => { + const key = `${offset}|${previousHost}`; + if (memo.has(key)) { + return memo.get(key); + } + if (offset >= source.length) { + const terminal = { score: 0, tokens: [] }; + memo.set(key, terminal); + return terminal; + } + + let best = null; + const hostCandidates = enumerateContiguousIpv4WithColonCandidates(source, offset); + for (const candidate of hostCandidates) { + const { host, portStart } = candidate; + for (let portLen = 2; portLen <= 5; portLen += 1) { + const portEnd = portStart + portLen; + if (portEnd > source.length) { + break; + } + const portText = source.slice(portStart, portEnd); + if (!/^\d+$/.test(portText)) { + break; + } + const port = Number.parseInt(portText, 10); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + continue; + } + const next = solve(portEnd, host); + if (!next) { + continue; + } + const hostContinuityBonus = previousHost + ? (previousHost === host ? 8 : -4) + : 0; + const portLengthBonus = portLen * 2; + const tinyPortPenalty = port < 1000 ? -40 : 0; + const score = next.score + 100 + hostContinuityBonus + portLengthBonus + tinyPortPenalty; + if (!best || score > best.score) { + best = { + score, + tokens: [{ + host, + port, + provider: normalizeIpProxyProviderValue(provider), + }, ...next.tokens], + }; + } + } + } + + memo.set(key, best); + return best; + }; + + const solved = solve(0, ''); + if (!solved || !Array.isArray(solved.tokens) || solved.tokens.length <= 0) { + return []; + } + return solved.tokens.map((item) => ({ + host: String(item.host || '').trim(), + port: normalizeIpProxyPort(item.port), + username: '', + password: '', + protocol: DEFAULT_IP_PROXY_PROTOCOL, + region: '', + provider: normalizeIpProxyProviderValue(item.provider || provider), + })).filter((item) => item.host && item.port); +} + +function normalizeProxyPoolEntries(value = [], provider = DEFAULT_IP_PROXY_SERVICE) { + const result = []; + const seen = new Set(); + const append = (entry) => { + if (!entry || !entry.host || !entry.port) { + return; + } + const normalizedEntry = { + host: String(entry.host || '').trim(), + port: normalizeIpProxyPort(entry.port), + username: String(entry.username || '').trim(), + password: String(entry.password || ''), + protocol: normalizeIpProxyProtocol(entry.protocol || ''), + region: String(entry.region || '').trim(), + provider: normalizeIpProxyProviderValue(entry.provider || provider), + }; + if (!normalizedEntry.host || !normalizedEntry.port) { + return; + } + const dedupKey = `${normalizedEntry.host}:${normalizedEntry.port}:${normalizedEntry.username}:${normalizedEntry.password}:${normalizedEntry.protocol}`; + if (seen.has(dedupKey)) { + return; + } + seen.add(dedupKey); + result.push(normalizedEntry); + }; + + if (typeof value === 'string') { + const rawText = String(value || ''); + normalizeIpProxyAccountList(rawText).split('\n').forEach((line) => { + append(parseIpProxyLine(line, provider)); + }); + if (!result.length) { + const contiguousEntries = parseContiguousProxyStream(rawText, provider); + contiguousEntries.forEach((entry) => append(entry)); + } + if (!result.length) { + // Lumi API 在 lb 参数异常时,可能返回无分隔的 host:port 串,这里做兜底拆分。 + const contiguousMatches = rawText.match(/\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}/g) || []; + contiguousMatches.forEach((token) => { + append(parseIpProxyLine(token, provider)); + }); + } + return result; + } + + if (Array.isArray(value)) { + value.forEach((item) => { + if (typeof item === 'string') { + append(parseIpProxyLine(item, provider)); + return; + } + append(normalizeIpProxyEntriesFromObjectCandidate(item, provider)); + }); + return result; + } + + if (value && typeof value === 'object') { + append(normalizeIpProxyEntriesFromObjectCandidate(value, provider)); + } + return result; +} + +function normalizeIpProxyListFromPayload(payload, provider = DEFAULT_IP_PROXY_SERVICE) { + if (Array.isArray(payload)) { + return normalizeProxyPoolEntries(payload, provider); + } + + if (!payload || typeof payload !== 'object') { + if (typeof payload === 'string') { + return normalizeProxyPoolEntries(payload, provider); + } + return []; + } + + const candidateArrays = [ + payload.data, + payload.list, + payload.items, + payload.proxies, + payload.result, + payload.rows, + ]; + for (const candidate of candidateArrays) { + if (Array.isArray(candidate) && candidate.length > 0) { + return normalizeProxyPoolEntries(candidate, provider); + } + } + + const singleCandidate = normalizeIpProxyEntriesFromObjectCandidate(payload, provider); + return singleCandidate ? [singleCandidate] : []; +} + +function getAccountModeProxyPoolFromState(state = {}, provider = DEFAULT_IP_PROXY_SERVICE) { + const normalizedProvider = normalizeIpProxyProviderValue(provider || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE); + const accountListEnabled = isIpProxyAccountListEnabled(); + const rawLines = normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean); + const lines = accountListEnabled ? rawLines : []; + const hasAccountList = lines.length > 0; + const transformState = hasAccountList + ? { + ...state, + ipProxyHost: '', + ipProxyPort: '', + ipProxyUsername: '', + ipProxyPassword: '', + ipProxyRegion: '', + ipProxyAccountSessionPrefix: '', + ipProxyAccountLifeMinutes: '', + } + : state; + let pool = lines.map((line) => parseIpProxyLine(line, normalizedProvider)).filter(Boolean); + + if (!pool.length && !hasAccountList) { + const host = String(state?.ipProxyHost || '').trim(); + const port = normalizeIpProxyPort(state?.ipProxyPort); + if (host && port) { + pool = [{ + host, + port, + username: String(state?.ipProxyUsername || '').trim(), + password: String(state?.ipProxyPassword || ''), + protocol: normalizeIpProxyProtocol(state?.ipProxyProtocol), + region: '', + provider: normalizedProvider, + }]; + } else if (!accountListEnabled && rawLines.length) { + const legacyEntry = parseIpProxyLine(rawLines[0], normalizedProvider); + if (legacyEntry) { + pool = [legacyEntry]; + } + } + } + + const configuredRegion = String(state?.ipProxyRegion || '').trim(); + if (!pool.length) { + return []; + } + + return pool.map((entry, index) => { + let nextEntry = { ...entry }; + if (typeof transformIpProxyAccountEntryByProvider === 'function') { + const transformed = transformIpProxyAccountEntryByProvider(normalizedProvider, nextEntry, { + state: transformState, + index, + hasAccountList, + }); + if (transformed && typeof transformed === 'object') { + nextEntry = transformed; + } + } + const inferredRegion = inferRegionFromProxyUsername(nextEntry.username); + if (inferredRegion) { + nextEntry.region = inferredRegion; + } else if (!String(nextEntry.region || '').trim()) { + const inferredHostRegion = inferRegionFromProxyHost(nextEntry.host); + if (inferredHostRegion) { + nextEntry.region = inferredHostRegion; + } + } + if (shouldApplyConfiguredRegionFallbackToEntry(nextEntry, { + provider: normalizedProvider, + configuredRegion, + hasAccountList, + })) { + nextEntry.region = configuredRegion; + } + return nextEntry; + }); +} + +function hasConfiguredAccountListEntries(state = {}) { + if (!isIpProxyAccountListEnabled()) { + return false; + } + const lines = normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean); + return lines.length > 0; +} + +function getAccountListParseFailureHint(state = {}, provider = DEFAULT_IP_PROXY_SERVICE) { + if (!isIpProxyAccountListEnabled()) { + return ''; + } + const normalizedProvider = normalizeIpProxyProviderValue(provider || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE); + const lines = normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean); + if (!lines.length) { + return ''; + } + const parsedCount = lines + .map((line) => parseIpProxyLine(line, normalizedProvider)) + .filter(Boolean) + .length; + if (parsedCount > 0) { + return ''; + } + if (normalizedProvider === '711proxy') { + return '账号列表已填写,但未解析出有效条目。请按每行 host:port:username:password 填写。'; + } + return '账号列表已填写,但未解析出有效条目。请检查列表格式。'; +} + +function resolveIpProxyPoolTargetCountForMode(state = {}, mode = normalizeIpProxyMode(state?.ipProxyMode)) { + const normalizedMode = normalizeIpProxyMode(mode); + // `ipProxyPoolTargetCount` 语义是“任务成功轮次阈值”, + // 拉取/切换代理池条数不再复用该字段,避免语义混淆。 + if (normalizedMode === 'account') { + return 500; + } + return 100; +} + +function resolveIpProxyAutoSwitchThreshold(state = {}) { + return Math.max( + 1, + Math.min(500, Number(normalizeIpProxyPoolTargetCount(state?.ipProxyPoolTargetCount, 20)) || 20) + ); +} + +function buildProxyPoolSummary(pool = [], preferredIndex = 0) { + const normalizedPool = Array.isArray(pool) ? pool : []; + if (!normalizedPool.length) { + return { + count: 0, + index: 0, + current: null, + display: '暂无可用代理', + }; + } + const index = normalizeIpProxyCurrentIndex(preferredIndex, 0) % normalizedPool.length; + const current = normalizedPool[index]; + const region = String(current?.region || '').trim(); + return { + count: normalizedPool.length, + index, + current, + display: `${current.host}:${current.port}${region ? ` [${region}]` : ''} (${index + 1}/${normalizedPool.length})`, + }; +} + +function getIpProxyRuntimeFieldNames(mode = DEFAULT_IP_PROXY_MODE) { + const normalizedMode = normalizeIpProxyMode(mode); + if (normalizedMode === 'account') { + return { + poolKey: 'ipProxyAccountPool', + indexKey: 'ipProxyAccountCurrentIndex', + currentKey: 'ipProxyAccountCurrent', + }; + } + return { + poolKey: 'ipProxyApiPool', + indexKey: 'ipProxyApiCurrentIndex', + currentKey: 'ipProxyApiCurrent', + }; +} + +function getIpProxyRuntimeSnapshot( + state = {}, + mode = normalizeIpProxyMode(state?.ipProxyMode), + provider = normalizeIpProxyProviderValue(state?.ipProxyService) +) { + const normalizedMode = normalizeIpProxyMode(mode); + const normalizedProvider = normalizeIpProxyProviderValue(provider); + const fields = getIpProxyRuntimeFieldNames(normalizedMode); + const hasModePool = Array.isArray(state?.[fields.poolKey]); + const hasModeCurrent = state?.[fields.currentKey] !== undefined && state?.[fields.currentKey] !== null; + const hasModeIndex = state?.[fields.indexKey] !== undefined && state?.[fields.indexKey] !== null; + const modePool = normalizeProxyPoolEntries(state?.[fields.poolKey], normalizedProvider); + const modeCurrent = normalizeProxyPoolEntries( + state?.[fields.currentKey] ? [state[fields.currentKey]] : [], + normalizedProvider + )[0] || null; + const pool = hasModePool ? modePool : []; + const current = hasModeCurrent ? modeCurrent : null; + const indexSource = hasModeIndex ? state?.[fields.indexKey] : 0; + return { + mode: normalizedMode, + provider: normalizedProvider, + ...fields, + hasModePool, + hasModeCurrent, + hasModeIndex, + pool, + current, + index: normalizeIpProxyCurrentIndex(indexSource, 0), + }; +} + +function isApiModeProxyConfigAvailable(state = {}) { + return Boolean(String(state?.ipProxyApiUrl || '').trim()); +} + +function buildIpProxyRuntimeStatePatch(mode = DEFAULT_IP_PROXY_MODE, runtime = {}, provider = DEFAULT_IP_PROXY_SERVICE) { + const normalizedMode = normalizeIpProxyMode(mode); + const normalizedProvider = normalizeIpProxyProviderValue(provider); + const fields = getIpProxyRuntimeFieldNames(normalizedMode); + const pool = normalizeProxyPoolEntries(runtime?.pool, normalizedProvider); + const summary = buildProxyPoolSummary(pool, runtime?.index); + const explicitCurrent = normalizeProxyPoolEntries( + runtime?.current ? [runtime.current] : [], + normalizedProvider + )[0] || null; + const current = explicitCurrent || summary.current || null; + return { + [fields.poolKey]: pool, + [fields.indexKey]: summary.index, + [fields.currentKey]: current, + // 兼容旧状态字段,保持当前激活模式的摘要可见。 + ipProxyPool: pool, + ipProxyCurrentIndex: summary.index, + ipProxyCurrent: current, + }; +} + +function getIpProxyCurrentEntryFromState(state = {}) { + const mode = normalizeIpProxyMode(state?.ipProxyMode); + const provider = normalizeIpProxyProviderValue(state?.ipProxyService); + if (mode === 'api' && !isApiModeProxyConfigAvailable(state)) { + return null; + } + const runtime = getIpProxyRuntimeSnapshot(state, mode, provider); + if (mode === 'account') { + const hasAccountList = hasConfiguredAccountListEntries(state); + const accountPool = getAccountModeProxyPoolFromState(state, provider); + if (hasAccountList) { + if (!accountPool.length) { + return null; + } + const index = normalizeIpProxyCurrentIndex(runtime.index, 0) % accountPool.length; + return accountPool[index]; + } + if (accountPool.length) { + const index = normalizeIpProxyCurrentIndex(runtime.index, 0) % accountPool.length; + return accountPool[index]; + } + const host = String(state?.ipProxyHost || '').trim(); + const port = normalizeIpProxyPort(state?.ipProxyPort); + if (!host || !port) return null; + const entry = { + host, + port, + username: String(state?.ipProxyUsername || '').trim(), + password: String(state?.ipProxyPassword || ''), + protocol: normalizeIpProxyProtocol(state?.ipProxyProtocol), + region: '', + provider, + }; + const inferredRegionFromUsername = inferRegionFromProxyUsername(entry.username); + if (inferredRegionFromUsername) { + entry.region = inferredRegionFromUsername; + } else { + const inferredRegionFromHost = inferRegionFromProxyHost(entry.host); + if (inferredRegionFromHost) { + entry.region = inferredRegionFromHost; + } + } + const configuredRegion = String(state?.ipProxyRegion || '').trim(); + if (shouldApplyConfiguredRegionFallbackToEntry(entry, { + provider, + configuredRegion, + hasAccountList: false, + })) { + entry.region = configuredRegion; + } + return entry; + } + + const pool = runtime.pool; + if (pool.length) { + const index = normalizeIpProxyCurrentIndex(runtime.index, 0) % pool.length; + return pool[index]; + } + const fallback = normalizeProxyPoolEntries(runtime.current ? [runtime.current] : [], provider)[0]; + return fallback || null; +} + +function buildIpProxyRoutingStatePatch(status = {}) { + const provider = normalizeIpProxyProviderValue(status?.provider || DEFAULT_IP_PROXY_SERVICE); + const host = String(status?.host || '').trim(); + const port = normalizeIpProxyPort(status?.port); + const region = String(status?.region || '').trim(); + const hasAuth = Boolean(status?.hasAuth); + const applied = Boolean(status?.applied); + const reason = String(status?.reason || (applied ? 'applied' : 'disabled')).trim().toLowerCase(); + const error = String(status?.error || '').trim(); + const warning = String(status?.warning || '').trim(); + const exitIp = String(status?.exitIp || '').trim(); + const exitRegion = String(status?.exitRegion || '').trim(); + const exitDetecting = Boolean(status?.exitDetecting); + const exitError = String(status?.exitError || '').trim(); + const exitSource = String(status?.exitSource || '').trim().toLowerCase(); + return { + ipProxyApplied: applied, + ipProxyAppliedReason: reason, + ipProxyAppliedAt: Date.now(), + ipProxyAppliedHost: host, + ipProxyAppliedPort: port, + ipProxyAppliedRegion: region, + ipProxyAppliedHasAuth: hasAuth, + ipProxyAppliedProvider: provider, + ipProxyAppliedError: error, + ipProxyAppliedWarning: warning, + ipProxyAppliedExitIp: exitIp, + ipProxyAppliedExitRegion: exitRegion, + ipProxyAppliedExitDetecting: exitDetecting, + ipProxyAppliedExitError: exitError, + ipProxyAppliedExitSource: exitSource, + }; +} + +async function setIpProxyLeakGuardEnabled(enabled) { + if (!chrome.declarativeNetRequest?.updateDynamicRules) { + return; + } + const removeRuleIds = [IP_PROXY_GUARD_BLOCK_RULE_ID]; + const addRules = enabled ? [{ + id: IP_PROXY_GUARD_BLOCK_RULE_ID, + priority: 100, + action: { + type: 'block', + }, + condition: { + regexFilter: IP_PROXY_GUARD_REGEX, + resourceTypes: [ + 'main_frame', + 'sub_frame', + 'xmlhttprequest', + 'websocket', + 'other', + ], + }, + }] : []; + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds, + addRules, + }).catch(() => { }); +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = IP_PROXY_FETCH_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || IP_PROXY_FETCH_TIMEOUT_MS)); + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +} + +function extractIpv4FromText(value = '') { + const text = String(value || '').trim(); + if (!text) { + return ''; + } + const ipv4Match = text.match(/\b(\d{1,3}(?:\.\d{1,3}){3})\b/); + if (ipv4Match) { + return ipv4Match[1]; + } + const ipv6Match = text.match(/\b([0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7})\b/i); + return ipv6Match ? String(ipv6Match[1] || '').trim() : ''; +} + +function normalizeProbeRegionCode(value = '') { + const code = String(value || '') + .trim() + .toUpperCase() + .replace(/[^A-Z]/g, ''); + return /^[A-Z]{2}$/.test(code) ? code : ''; +} + +function pickProbeRegion(parsed = {}) { + const countryCodeCandidates = [ + parsed?.countryCode, + parsed?.country_code, + parsed?.countrycode, + parsed?.countryAlpha2, + parsed?.country_alpha2, + parsed?.countryISO, + parsed?.countryIso, + parsed?.iso_country, + parsed?.geo?.countryCode, + parsed?.geo?.country_code, + parsed?.location?.countryCode, + parsed?.location?.country_code, + ]; + for (const candidate of countryCodeCandidates) { + const code = normalizeProbeRegionCode(candidate); + if (code) { + return code; + } + } + + const fallbackCandidates = [ + parsed?.regionCode, + parsed?.region_code, + parsed?.country, + parsed?.country_name, + parsed?.countryName, + parsed?.regionName, + parsed?.region, + parsed?.city, + ]; + for (const candidate of fallbackCandidates) { + const text = String(candidate || '').trim(); + if (text) { + return text; + } + } + return ''; +} + +function parseProxyExitProbePayload(rawText = '', contentType = '') { + const text = String(rawText || '').trim(); + if (!text) { + return { ip: '', region: '' }; + } + if (/^[-_a-zA-Z0-9]+=/m.test(text)) { + const kv = {}; + text.split(/\r?\n+/).forEach((line) => { + const index = line.indexOf('='); + if (index <= 0) return; + const key = String(line.slice(0, index) || '').trim().toLowerCase(); + if (!key) return; + kv[key] = String(line.slice(index + 1) || '').trim(); + }); + const kvIp = extractIpv4FromText( + kv.ip + || kv.query + || kv.clientip + || kv.client_ip + || '' + ); + const kvRegion = normalizeProbeRegionCode(kv.loc || kv.country_code || kv.countrycode || ''); + if (kvIp) { + return { + ip: kvIp, + region: kvRegion, + }; + } + } + const normalizedContentType = String(contentType || '').toLowerCase(); + if (normalizedContentType.includes('application/json') || text.startsWith('{') || text.startsWith('[')) { + try { + const parsed = JSON.parse(text); + const ip = extractIpv4FromText( + parsed?.ip + || parsed?.query + || parsed?.origin + || parsed?.address + || parsed?.ipAddress + || parsed?.ip_address + || '' + ); + const region = pickProbeRegion(parsed); + if (ip) { + return { ip, region }; + } + } catch { + // fall through to regex parse + } + } + + const ip = extractIpv4FromText(text); + if (!ip) { + return { ip: '', region: '' }; + } + return { + ip, + region: '', + }; +} + +function normalizeRegionToken(value = '') { + return String(value || '') + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); +} + +function hasProxyExitRegionMismatch(expectedRegion = '', detectedRegion = '') { + const expected = normalizeRegionToken(expectedRegion); + const detected = normalizeRegionToken(detectedRegion); + if (!expected || !detected) { + return false; + } + if (expected === detected) { + return false; + } + if (expected.length >= 2 && detected.includes(expected)) { + return false; + } + if (detected.length >= 2 && expected.includes(detected)) { + return false; + } + return true; +} + +function has711SessionToken(username = '') { + const text = String(username || '').trim(); + if (!text) { + return false; + } + return /(?:^|[-_])session[-_:][A-Za-z0-9_-]+\b/i.test(text); +} + +function resolveExitProbeEndpoints(options = {}) { + const provider = normalizeIpProxyProviderValue(options?.provider || ''); + const username = String(options?.username || '').trim(); + if (provider === '711proxy' && has711SessionToken(username)) { + return IP_PROXY_EXIT_PROBE_ENDPOINTS_711_STICKY.slice(); + } + return IP_PROXY_EXIT_PROBE_ENDPOINTS.slice(); +} + +function applyExitRegionExpectation(status = {}, expectedRegion = '') { + const exitIp = String(status?.exitIp || '').trim(); + const exitError = String(status?.exitError || '').trim(); + if (!exitIp) { + const hasBackgroundAbortStorm = /probe:bg:/.test(exitError) + && /signal is aborted without reason/i.test(exitError); + const hasBackgroundAbortFallback = /probe:bg:abort_storm_page_fallback/i.test(exitError); + const hasProxyRuntimeError = /probe:proxy_error\(/i.test(exitError); + const hasAuth = Boolean(status?.hasAuth); + const challengeMatch = exitError.match(/challenge=(\d+)/i); + const providedMatch = exitError.match(/provided=(\d+)/i); + const challengeCount = Number.parseInt(String(challengeMatch?.[1] || ''), 10) || 0; + const providedCount = Number.parseInt(String(providedMatch?.[1] || ''), 10) || 0; + const missingProxyChallenge = hasAuth && challengeCount <= 0 && providedCount <= 0; + const hasErrorPageFailure = /Frame with ID 0 is showing error page|Cannot access contents of the page/i.test(exitError); + const netErrorCode = exitError.match(/net::ERR_[A-Z_]+/i)?.[0] || ''; + const navigationHint = netErrorCode + ? `导航错误:${netErrorCode}。` + : ''; + const tunnelFailedWithoutChallenge = missingProxyChallenge + && /ERR_TUNNEL_CONNECTION_FAILED/i.test(netErrorCode || exitError); + const humanizedError = hasErrorPageFailure + ? ( + hasAuth + ? '探测页已进入浏览器错误页(常见于代理鉴权失败、代理节点不可用或网络被拦截)。请先检查代理账号/密码与节点可用性,再重试检测。' + : '探测页已进入浏览器错误页(常见于代理节点不可用、代理协议不匹配或网络被拦截)。请先检查代理节点、端口与协议配置,再重试检测。' + ) + : ''; + const authDiagnostics = exitError.match(/probe:auth\([^)]+\)/i)?.[0] || ''; + const challengeHint = missingProxyChallenge + ? '当前链路未收到 407 代理鉴权挑战(可能是代理端返回非标准拒绝,例如 630 no Proxy-Authorization),浏览器无法触发扩展鉴权回填。建议切换 API 模式,或更换会返回标准 407 的账号节点。' + : ''; + const compatibilityHint = tunnelFailedWithoutChallenge + ? '当前账号模式节点可能采用非标准代理鉴权链路(未发起 407 挑战),该链路在 Chrome 扩展代理 API 下无法自动回填凭据。' + : ''; + const mergedErrorParts = [ + hasBackgroundAbortStorm + ? ( + hasBackgroundAbortFallback + ? '后台探测请求连续超时中断(代理隧道未在时限内建立),且已自动回退页面探测但仍未拿到可用出口。' + : '后台探测请求连续超时中断(代理隧道未在时限内建立)。这通常是代理节点链路不可用或被上游丢弃,并非探测页面本身异常。' + ) + : '', + hasProxyRuntimeError + ? '已捕获浏览器代理栈错误事件(chrome.proxy.onProxyError),请优先依据诊断中的 proxy_error 字段排查节点/协议兼容性。' + : '', + humanizedError, + navigationHint, + compatibilityHint, + challengeHint, + ].filter(Boolean); + const mergedError = mergedErrorParts.length + ? `${mergedErrorParts.join(' ')}${authDiagnostics ? ` 诊断:${authDiagnostics}` : ''}` + : ''; + const mergedWithDiagnostics = mergedError + ? ( + exitError + && !mergedError.includes(exitError) + ? `${mergedError} 诊断:${exitError}` + : mergedError + ) + : ''; + return { + ...status, + applied: false, + reason: 'connectivity_failed', + error: mergedWithDiagnostics || exitError || '未检测到代理出口 IP,无法确认代理已生效。', + }; + } + + const expected = String(expectedRegion || '').trim(); + const entrySource = String(status?.entrySource || '').trim(); + const normalizedProvider = normalizeIpProxyProviderValue(status?.provider || DEFAULT_IP_PROXY_SERVICE); + const authSummary = String(status?.authDiagnostics || '').trim(); + const parsedAuthSummary = parseIpProxyAuthDiagnosticsSummary(authSummary); + const missingAuthChallenge = Boolean(status?.hasAuth) + && parsedAuthSummary + && parsedAuthSummary.challenge <= 0 + && parsedAuthSummary.provided <= 0; + if (!expected) { + return status; + } + + const detected = String(status?.exitRegion || '').trim(); + if (!detected) { + return { + ...status, + applied: false, + reason: 'connectivity_failed', + error: `已检测到出口 IP ${exitIp},但地区探测未返回国家/地区代码,暂无法校验期望地区 ${expected}。`, + }; + } + + if (!hasProxyExitRegionMismatch(expected, detected)) { + return status; + } + const sourceHint = entrySource === 'account_list' + ? '(来源:账号列表)' + : (entrySource === 'fixed_account' ? '(来源:固定账号)' : ''); + const usernameRegion = inferRegionFromProxyUsername(status?.username || ''); + const usernameHint = usernameRegion + ? ( + hasProxyExitRegionMismatch(expected, usernameRegion) + ? ` 当前账号用户名地区标记为 ${usernameRegion},与期望不一致。` + : ` 当前账号用户名地区标记为 ${usernameRegion}。` + ) + : ''; + const missingAuthChallengeHint = Boolean(status?.hasAuth) + && parsedAuthSummary + && parsedAuthSummary.challenge <= 0 + && parsedAuthSummary.provided <= 0 + ? ' 当前链路未触发代理鉴权挑战(407),可能复用了历史代理连接/鉴权缓存,或代理端未返回标准 407 导致账号参数未被浏览器回填(可能走匿名链路)。可先关闭再开启代理,或切换到下一条节点后重试。' + : ''; + const authHint = authSummary ? ` 诊断:probe:${authSummary}` : ''; + const mismatchMessage = `代理出口地区与预期不一致:期望 ${expected},实际 ${detected || 'unknown'}${sourceHint}。${usernameHint}${missingAuthChallengeHint}${authHint}`.trim(); + if (normalizedProvider === '711proxy') { + if (missingAuthChallenge) { + return { + ...status, + applied: false, + reason: 'connectivity_failed', + error: mismatchMessage, + warning: '', + }; + } + return { + ...status, + applied: true, + reason: 'applied_with_warning', + warning: `地区校验未通过,但已保留代理接管:${mismatchMessage}`, + error: '', + }; + } + return { + ...status, + applied: false, + reason: 'connectivity_failed', + error: mismatchMessage, + }; +} + +function applyExitBaselineExpectation(status = {}) { + const exitIp = String(status?.exitIp || '').trim(); + const baselineIp = String(status?.exitBaselineIp || '').trim(); + const exitSource = String(status?.exitSource || '').trim().toLowerCase(); + if (exitIp && exitSource.includes('background') && !baselineIp) { + return { + ...status, + applied: false, + reason: 'connectivity_failed', + error: `已检测到出口 IP ${exitIp},但系统基线网络探测失败,无法确认是否经过插件代理链路。`, + }; + } + if (!exitIp || !baselineIp) { + return status; + } + if (exitIp !== baselineIp) { + return status; + } + return { + ...status, + applied: false, + reason: 'connectivity_failed', + error: `检测到出口 IP ${exitIp} 与系统基线网络一致,疑似未经过插件代理链路。`, + }; +} + +async function detectProxyExitInfoByBackgroundFetch(options = {}) { + const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000; + const errors = Array.isArray(options?.errors) ? options.errors : []; + const configuredEndpoints = Array.isArray(options?.probeEndpoints) + ? options.probeEndpoints.map((item) => String(item || '').trim()).filter(Boolean) + : []; + const availableEndpoints = configuredEndpoints.length + ? configuredEndpoints + : IP_PROXY_EXIT_PROBE_ENDPOINTS; + const requestedPerEndpointTimeoutMs = Number(options?.backgroundPerEndpointTimeoutMs); + const perRequestTimeoutMs = Math.max( + 1200, + Math.min( + Number.isFinite(requestedPerEndpointTimeoutMs) && requestedPerEndpointTimeoutMs > 0 + ? requestedPerEndpointTimeoutMs + : IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS, + timeoutMs + ) + ); + const maxEndpoints = Math.max( + 1, + Math.min( + availableEndpoints.length, + Number.isFinite(Number(options?.backgroundMaxEndpoints)) + ? Number(options.backgroundMaxEndpoints) + : IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS + ) + ); + const probeEndpoints = availableEndpoints.slice(0, maxEndpoints); + let baselineIp = ''; + try { + const baselineUrl = `${IP_PROXY_PAGE_CONTEXT_BASELINE_URL}?_multipage_proxy_baseline=${Date.now()}`; + const baselineResponse = await fetchWithTimeout(baselineUrl, { + method: 'GET', + cache: 'no-store', + headers: { Accept: 'application/json, text/plain, */*' }, + }, perRequestTimeoutMs); + if (!baselineResponse.ok) { + errors.push(`probe:bg:baseline:status:${baselineResponse.status}`); + } else { + const baselineText = await baselineResponse.text(); + const parsedBaseline = parseProxyExitProbePayload( + baselineText, + baselineResponse.headers.get('content-type') + ); + baselineIp = String(parsedBaseline?.ip || '').trim(); + if (!baselineIp) { + errors.push('probe:bg:baseline:empty_parse'); + } + } + } catch (baselineError) { + errors.push(`probe:bg:baseline:${baselineError?.message || baselineError}`); + } + const runProbePass = async (endpoints, requestTimeoutMs, diagnosticPrefix = 'probe:bg') => { + let bestEffortResult = null; + let abortLikeCount = 0; + for (const endpoint of endpoints) { + try { + const url = endpoint.includes('?') ? `${endpoint}&_t=${Date.now()}` : `${endpoint}?_t=${Date.now()}`; + const response = await fetchWithTimeout(url, { + method: 'GET', + cache: 'no-store', + headers: { Accept: 'application/json, text/plain, */*' }, + }, requestTimeoutMs); + if (!response.ok) { + errors.push(`${diagnosticPrefix}:${endpoint}:status:${response.status}`); + continue; + } + const text = await response.text(); + const parsed = parseProxyExitProbePayload(text, response.headers.get('content-type')); + if (parsed.ip) { + const result = { + ip: parsed.ip, + region: parsed.region, + source: 'background_fallback', + endpoint, + baselineIp, + }; + if (parsed.region) { + return { + result, + bestEffortResult: result, + abortLikeCount, + }; + } + if (!bestEffortResult) { + bestEffortResult = result; + } + errors.push(`${diagnosticPrefix}:${endpoint}:missing_region`); + continue; + } + errors.push(`${diagnosticPrefix}:${endpoint}:empty_parse`); + } catch (error) { + const message = String(error?.message || error || '').trim(); + if (/signal is aborted without reason|abort/i.test(message)) { + abortLikeCount += 1; + } + errors.push(`${diagnosticPrefix}:${endpoint}:${message}`); + } + } + return { + result: bestEffortResult, + bestEffortResult, + abortLikeCount, + }; + }; + + const firstPass = await runProbePass(probeEndpoints, perRequestTimeoutMs, 'probe:bg'); + if (firstPass?.result?.ip && firstPass?.result?.region) { + return firstPass.result; + } + + if (!firstPass?.result?.ip + && firstPass?.abortLikeCount >= Math.min(2, probeEndpoints.length) + && options?.backgroundSlowRetry !== false) { + const retryTimeoutMs = Math.max(perRequestTimeoutMs + 2500, 7000); + const retryEndpoints = probeEndpoints.slice(0, Math.min(2, probeEndpoints.length)); + errors.push(`probe:bg:slow_retry:${retryTimeoutMs}ms`); + const retryPass = await runProbePass(retryEndpoints, retryTimeoutMs, 'probe:bg:retry'); + if (retryPass?.result?.ip) { + return retryPass.result; + } + } + + if (!firstPass?.bestEffortResult?.ip) { + appendIpProxyRuntimeErrorDiagnosticsToErrors(errors, 45000); + } + + return firstPass?.bestEffortResult || { ip: '', region: '', source: 'background_fallback', baselineIp }; +} + +function isPageContextProbeHost(hostname = '') { + const normalized = String(hostname || '').trim().toLowerCase(); + return normalized === 'chatgpt.com' + || normalized.endsWith('.chatgpt.com') + || normalized === 'openai.com' + || normalized.endsWith('.openai.com') + || normalized === 'example.com' + || normalized.endsWith('.example.com') + || normalized === 'ip-api.com' + || normalized.endsWith('.ip-api.com') + || normalized === 'httpbin.org' + || normalized === 'ipwho.is' + || normalized.endsWith('.ipwho.is') + || normalized === 'ipinfo.io' + || normalized.endsWith('.ipinfo.io') + || normalized === 'ipapi.co' + || normalized.endsWith('.ipapi.co'); +} + +function isProbeErrorPageExecutionError(error) { + const message = String(error?.message || error || ''); + return /Frame with ID 0 is showing error page|Cannot access contents of the page/i.test(message); +} + +function hasProbeErrorPageDiagnostics(errors = []) { + if (!Array.isArray(errors) || !errors.length) { + return false; + } + return errors.some((item) => isProbeErrorPageExecutionError(String(item || ''))); +} + +function createProbeNavigationErrorTracker(initialTabId = null) { + if (!chrome.webNavigation?.onErrorOccurred?.addListener) { + return { + setTabId: () => {}, + appendDiagnostics: () => {}, + dispose: () => {}, + }; + } + + let activeTabId = Number.isInteger(initialTabId) ? Number(initialTabId) : null; + const entries = []; + const listener = (details = {}) => { + if (!Number.isInteger(activeTabId) || Number(details?.tabId) !== activeTabId) { + return; + } + if (Number(details?.frameId) !== 0) { + return; + } + const errorCode = String(details?.error || '').trim(); + if (!errorCode) { + return; + } + const url = String(details?.url || '').trim(); + entries.push({ + error: errorCode, + host: extractProbeHostFromTabUrl(url), + url, + }); + }; + + try { + chrome.webNavigation.onErrorOccurred.addListener(listener); + } catch { + return { + setTabId: () => {}, + appendDiagnostics: () => {}, + dispose: () => {}, + }; + } + + return { + setTabId(nextTabId) { + activeTabId = Number.isInteger(nextTabId) ? Number(nextTabId) : null; + }, + appendDiagnostics(errors, maxItems = 2) { + if (!Array.isArray(errors) || !entries.length) { + return; + } + const limit = Math.max(1, Number(maxItems) || 2); + const recent = entries.slice(-limit); + for (const item of recent) { + const errorCode = String(item?.error || '').trim(); + if (!errorCode) { + continue; + } + const host = String(item?.host || '').trim() || 'unknown'; + errors.push(`probe:page_context:navigation_error:${errorCode}@${host}`); + } + }, + dispose() { + try { + chrome.webNavigation.onErrorOccurred.removeListener(listener); + } catch { + // ignore remove listener failures + } + }, + }; +} + +function extractProbeHostFromTabUrl(rawUrl = '') { + try { + const parsed = new URL(rawUrl); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return ''; + } + return parsed.hostname || ''; + } catch { + return ''; + } +} + +async function pickExistingPageContextProbeTabId() { + const tabs = await chrome.tabs.query({}); + const candidates = tabs + .filter((tab) => Number.isInteger(tab?.id)) + .filter((tab) => isPageContextProbeHost(extractProbeHostFromTabUrl(tab?.url || ''))); + if (!candidates.length) { + return null; + } + const preferred = candidates.find((tab) => String(tab?.status || '').toLowerCase() === 'complete'); + return Number((preferred || candidates[0]).id) || null; +} + +async function waitForPageContextProbeTabReady(tabId, timeoutMs = 15000) { + const timeout = Math.max(1500, Number(timeoutMs) || 15000); + + if (typeof waitForTabComplete === 'function') { + const result = await waitForTabComplete(tabId, { + timeoutMs: timeout, + retryDelayMs: 300, + }); + return Boolean(result?.id); + } + + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const tab = await chrome.tabs.get(tabId); + if (String(tab?.status || '').toLowerCase() === 'complete') { + return true; + } + } catch { + return false; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + return false; +} + +async function readExitProbeFromTabDocument(tabId) { + const executionResults = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'ISOLATED', + func: () => { + const preText = String(document.querySelector('pre')?.textContent || '').trim(); + const bodyText = String(document.body?.innerText || document.documentElement?.innerText || '').trim(); + return { + url: String(location.href || ''), + text: preText || bodyText, + }; + }, + }); + return executionResults?.[0]?.result || null; +} + +async function probeExitInfoByTabNavigation(tabId, timeoutMs = 10000, errors = []) { + return probeExitInfoByTabNavigationWithEndpoints(tabId, timeoutMs, errors, []); +} + +async function probeExitInfoByTabNavigationWithEndpoints(tabId, timeoutMs = 10000, errors = [], endpointsOverride = []) { + if (!Number.isInteger(tabId)) { + return null; + } + const configuredEndpoints = Array.isArray(endpointsOverride) + ? endpointsOverride.map((item) => String(item || '').trim()).filter(Boolean) + : []; + const availableEndpoints = configuredEndpoints.length + ? configuredEndpoints + : IP_PROXY_EXIT_PROBE_ENDPOINTS; + const endpoints = availableEndpoints.slice(0, 6); + const perEndpointTimeoutMs = Math.max( + 2000, + Math.min(IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS, Number(timeoutMs) || 10000) + ); + let bestEffortResult = null; + + for (const endpoint of endpoints) { + const endpointText = String(endpoint || '').trim(); + if (!endpointText) { + continue; + } + const probeTargetUrl = endpointText.includes('?') + ? `${endpointText}&_multipage_proxy_probe=${Date.now()}` + : `${endpointText}?_multipage_proxy_probe=${Date.now()}`; + try { + await chrome.tabs.update(tabId, { + url: probeTargetUrl, + active: false, + }); + const ready = await waitForPageContextProbeTabReady(tabId, perEndpointTimeoutMs); + if (!ready) { + if (Array.isArray(errors)) { + errors.push(`probe:page_context:navigation:${endpointText}:not_ready`); + } + continue; + } + const documentResult = await readExitProbeFromTabDocument(tabId); + const parsed = parseProxyExitProbePayload(String(documentResult?.text || ''), 'application/json'); + if (parsed?.ip) { + const result = { + ip: String(parsed.ip || '').trim(), + region: String(parsed.region || '').trim(), + endpoint: String(documentResult?.url || probeTargetUrl || endpointText).trim(), + source: 'page_context_navigation', + }; + if (result.region) { + return result; + } + if (!bestEffortResult) { + bestEffortResult = result; + } + if (Array.isArray(errors)) { + errors.push(`probe:page_context:navigation:${endpointText}:missing_region`); + } + continue; + } + if (Array.isArray(errors)) { + errors.push(`probe:page_context:navigation:${endpointText}:empty_parse`); + } + } catch (error) { + if (Array.isArray(errors)) { + errors.push(`probe:page_context:navigation:${endpointText}:${error?.message || error}`); + } + } + } + + return bestEffortResult; +} + +async function probeExitInfoViaExecuteScript(tabId, timeoutMs = 10000, endpointsOverride = []) { + const perRequestTimeoutMs = Math.max( + 1200, + Math.min(IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS, Number(timeoutMs) || 10000) + ); + const configuredEndpoints = Array.isArray(endpointsOverride) + ? endpointsOverride.map((item) => String(item || '').trim()).filter(Boolean) + : []; + const availableEndpoints = configuredEndpoints.length + ? configuredEndpoints + : IP_PROXY_EXIT_PROBE_ENDPOINTS; + const endpoints = availableEndpoints.slice(0, IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS); + const executionResults = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'ISOLATED', + func: async (payload) => { + const extractIpv4 = (value) => { + const text = String(value || '').trim(); + if (!text) return ''; + const match = text.match(/\b(\d{1,3}(?:\.\d{1,3}){3})\b/); + return match ? match[1] : ''; + }; + const normalizeRegionCode = (value) => { + const code = String(value || '') + .trim() + .toUpperCase() + .replace(/[^A-Z]/g, ''); + return /^[A-Z]{2}$/.test(code) ? code : ''; + }; + const pickRegion = (parsed) => { + const codeCandidates = [ + parsed?.countryCode, + parsed?.country_code, + parsed?.countrycode, + parsed?.countryAlpha2, + parsed?.country_alpha2, + parsed?.countryISO, + parsed?.countryIso, + parsed?.iso_country, + parsed?.geo?.countryCode, + parsed?.geo?.country_code, + parsed?.location?.countryCode, + parsed?.location?.country_code, + ]; + for (const candidate of codeCandidates) { + const code = normalizeRegionCode(candidate); + if (code) return code; + } + const fallbackCandidates = [ + parsed?.regionCode, + parsed?.region_code, + parsed?.country, + parsed?.country_name, + parsed?.countryName, + parsed?.regionName, + parsed?.region, + parsed?.city, + ]; + for (const candidate of fallbackCandidates) { + const text = String(candidate || '').trim(); + if (text) return text; + } + return ''; + }; + const parsePayload = (rawText, contentType) => { + const text = String(rawText || '').trim(); + if (!text) { + return { ip: '', region: '' }; + } + + const normalizedContentType = String(contentType || '').toLowerCase(); + if (normalizedContentType.includes('application/json') || text.startsWith('{') || text.startsWith('[')) { + try { + const parsed = JSON.parse(text); + const ip = extractIpv4( + parsed?.ip + || parsed?.query + || parsed?.origin + || parsed?.address + || parsed?.ipAddress + || parsed?.ip_address + || '' + ); + const region = pickRegion(parsed); + if (ip) { + return { ip, region }; + } + } catch { + // fall through to regex parse + } + } + + const ipMatch = extractIpv4(text); + return { + ip: ipMatch || '', + region: '', + }; + }; + + const fetchWithTimeout = async (url, timeoutValue) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutValue) || 10000)); + try { + return await fetch(url, { + method: 'GET', + cache: 'no-store', + credentials: 'omit', + headers: { + Accept: 'application/json, text/plain, */*', + }, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + }; + + const endpoints = Array.isArray(payload?.endpoints) ? payload.endpoints : []; + const normalizedEndpoints = []; + const endpointSet = new Set(); + for (const endpoint of endpoints) { + const text = String(endpoint || '').trim(); + if (!text || !/^https?:\/\//i.test(text) || endpointSet.has(text)) { + continue; + } + endpointSet.add(text); + normalizedEndpoints.push(text); + } + const timeoutValue = Math.max(1000, Number(payload?.timeoutMs) || 10000); + const diagnostics = []; + let bestEffortResult = null; + + for (const endpoint of normalizedEndpoints.slice(0, 8)) { + try { + const url = endpoint.includes('?') ? `${endpoint}&_t=${Date.now()}` : `${endpoint}?_t=${Date.now()}`; + const response = await fetchWithTimeout(url, timeoutValue); + if (!response.ok) { + diagnostics.push(`${endpoint}:status:${response.status}`); + continue; + } + const text = await response.text(); + const parsed = parsePayload(text, response.headers.get('content-type')); + if (parsed.ip) { + const result = { + ip: parsed.ip, + region: parsed.region, + endpoint, + diagnostics, + }; + if (parsed.region) { + return result; + } + if (!bestEffortResult) { + bestEffortResult = result; + } + diagnostics.push(`${endpoint}:missing_region`); + continue; + } + diagnostics.push(`${endpoint}:empty_parse`); + } catch (error) { + diagnostics.push(`${endpoint}:${error?.message || error}`); + } + } + + return bestEffortResult || { + ip: '', + region: '', + endpoint: '', + diagnostics, + }; + }, + args: [{ + endpoints, + timeoutMs: perRequestTimeoutMs, + }], + }); + + return executionResults?.[0]?.result || null; +} + +async function detectProxyExitInfoByPageContext(options = {}) { + const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000; + const errors = Array.isArray(options?.errors) ? options.errors : []; + const probeEndpoints = Array.isArray(options?.probeEndpoints) + ? options.probeEndpoints.map((item) => String(item || '').trim()).filter(Boolean) + : []; + let sawProbeErrorPage = false; + + if (!chrome.scripting?.executeScript) { + errors.push('probe:page_context:scripting_unavailable'); + return { ip: '', region: '', source: 'page_context_unavailable' }; + } + + let probeTabId = null; + let createdProbeTabId = null; + const probeUrl = `${IP_PROXY_PAGE_CONTEXT_PROBE_URL}?_multipage_proxy_probe=${Date.now()}`; + try { + const tab = await chrome.tabs.create({ + url: probeUrl, + active: false, + }); + probeTabId = Number(tab?.id) || null; + createdProbeTabId = probeTabId; + } catch (error) { + errors.push(`probe:page_context:create_probe_tab_failed:${error?.message || error}`); + probeTabId = await pickExistingPageContextProbeTabId(); + } + + if (!Number.isInteger(probeTabId)) { + errors.push('probe:page_context:no_probe_tab'); + return { ip: '', region: '', source: 'page_context_unavailable' }; + } + + const navigationErrorTracker = createProbeNavigationErrorTracker(probeTabId); + let hasSuccessfulIp = false; + + try { + const ready = await waitForPageContextProbeTabReady( + probeTabId, + Math.max(2500, Math.min(IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS, timeoutMs + 2500)) + ); + if (!ready) { + errors.push('probe:page_context:probe_tab_not_ready'); + return { ip: '', region: '', source: 'page_context_unavailable' }; + } + + let ip = ''; + let region = ''; + let endpoint = ''; + let canUseDocumentProbe = false; + try { + const probeTab = await chrome.tabs.get(probeTabId); + const probeHost = extractProbeHostFromTabUrl(probeTab?.url || ''); + const expectedHost = extractProbeHostFromTabUrl(probeUrl); + canUseDocumentProbe = Boolean(probeHost && expectedHost && probeHost === expectedHost); + } catch { + canUseDocumentProbe = false; + } + + if (canUseDocumentProbe) { + try { + const documentResult = await readExitProbeFromTabDocument(probeTabId); + const parsedDocument = parseProxyExitProbePayload(String(documentResult?.text || ''), 'application/json'); + ip = String(parsedDocument?.ip || '').trim(); + region = String(parsedDocument?.region || '').trim(); + endpoint = String(documentResult?.url || '').trim(); + if (ip) { + let baselineIp = ''; + try { + const baselineUrl = `${IP_PROXY_PAGE_CONTEXT_BASELINE_URL}?_multipage_proxy_baseline=${Date.now()}`; + await chrome.tabs.update(probeTabId, { + url: baselineUrl, + active: false, + }); + const baselineReady = await waitForPageContextProbeTabReady( + probeTabId, + Math.max(2000, Math.min(IP_PROXY_PAGE_CONTEXT_BASELINE_TIMEOUT_MS, timeoutMs + 1500)) + ); + if (baselineReady) { + const baselineResult = await readExitProbeFromTabDocument(probeTabId); + const parsedBaseline = parseProxyExitProbePayload(String(baselineResult?.text || ''), 'application/json'); + baselineIp = String(parsedBaseline?.ip || '').trim(); + if (!baselineIp) { + errors.push('probe:page_context:baseline_empty_parse'); + } + } else { + errors.push('probe:page_context:baseline_not_ready'); + } + } catch (baselineError) { + errors.push(`probe:page_context:baseline_failed:${baselineError?.message || baselineError}`); + } + hasSuccessfulIp = true; + return { + ip, + region, + source: 'page_context', + endpoint: endpoint || probeUrl, + baselineIp, + }; + } + errors.push('probe:page_context:document_empty_parse'); + } catch (documentError) { + if (isProbeErrorPageExecutionError(documentError)) { + sawProbeErrorPage = true; + } + errors.push(`probe:page_context:document_failed:${documentError?.message || documentError}`); + } + } else { + errors.push('probe:page_context:document_probe_skipped'); + } + + const navigationProbeResult = await probeExitInfoByTabNavigationWithEndpoints( + probeTabId, + timeoutMs, + errors, + probeEndpoints + ); + if (navigationProbeResult?.ip) { + hasSuccessfulIp = true; + return { + ip: String(navigationProbeResult.ip || '').trim(), + region: String(navigationProbeResult.region || '').trim(), + source: 'page_context', + endpoint: String(navigationProbeResult.endpoint || '').trim(), + }; + } + + let scriptResult = null; + try { + scriptResult = await probeExitInfoViaExecuteScript(probeTabId, timeoutMs, probeEndpoints); + } catch (scriptError) { + if (isProbeErrorPageExecutionError(scriptError)) { + sawProbeErrorPage = true; + } + errors.push(`probe:page_context:script_failed:${scriptError?.message || scriptError}`); + if (!createdProbeTabId && isProbeErrorPageExecutionError(scriptError)) { + const retryUrl = `${IP_PROXY_PAGE_CONTEXT_PROBE_URL}?_multipage_proxy_probe=${Date.now()}&retry=1`; + const retryTab = await chrome.tabs.create({ + url: retryUrl, + active: false, + }); + const retryTabId = Number(retryTab?.id) || 0; + if (retryTabId > 0) { + createdProbeTabId = retryTabId; + navigationErrorTracker.setTabId(retryTabId); + const retryReady = await waitForPageContextProbeTabReady( + retryTabId, + Math.max(2500, Math.min(IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS, timeoutMs + 2500)) + ); + if (retryReady) { + scriptResult = await probeExitInfoViaExecuteScript(retryTabId, timeoutMs, probeEndpoints); + } + } + } else { + throw scriptError; + } + } + + if (!scriptResult) { + return { + ip: '', + region: '', + source: sawProbeErrorPage ? 'page_context_error_page' : 'page_context_unavailable', + }; + } + ip = String(scriptResult?.ip || '').trim(); + region = String(scriptResult?.region || '').trim(); + if (ip) { + hasSuccessfulIp = true; + return { + ip, + region, + source: 'page_context', + endpoint: String(scriptResult?.endpoint || '').trim(), + }; + } + if (Array.isArray(scriptResult?.diagnostics) && scriptResult.diagnostics.length) { + for (const item of scriptResult.diagnostics.slice(0, 4)) { + errors.push(`probe:page_context:script:${String(item)}`); + } + } else { + errors.push('probe:page_context:script_empty'); + } + } catch (error) { + const message = String(error?.message || error || ''); + if (isProbeErrorPageExecutionError(error)) { + sawProbeErrorPage = true; + } + if (!/probe:page_context:script_failed/i.test(message)) { + errors.push(`probe:page_context:script_failed:${message}`); + } + } finally { + if (!hasSuccessfulIp) { + navigationErrorTracker.appendDiagnostics(errors, 2); + } + navigationErrorTracker.dispose(); + if (Number.isInteger(createdProbeTabId)) { + try { + await chrome.tabs.remove(createdProbeTabId); + } catch { + // ignore tab close failures + } + } + } + + return { + ip: '', + region: '', + source: sawProbeErrorPage ? 'page_context_error_page' : 'page_context_unavailable', + }; +} + +async function detectProxyExitInfo(options = {}) { + const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000; + const errors = Array.isArray(options?.errors) ? options.errors : []; + const preferPageContext = options?.preferPageContext !== false; + const allowBackgroundFallback = options?.allowBackgroundFallback === true; + const allowPageContextFallbackOnBackgroundAbort = options?.allowPageContextFallbackOnBackgroundAbort !== false; + const probeEndpoints = resolveExitProbeEndpoints({ + provider: options?.provider || '', + username: options?.username || '', + }); + + if (preferPageContext) { + const pageResult = await detectProxyExitInfoByPageContext({ + timeoutMs, + errors, + probeEndpoints, + }); + if (pageResult?.ip) { + return pageResult; + } + const shouldFallbackToBackground = allowBackgroundFallback + || String(pageResult?.source || '').trim().toLowerCase() === 'page_context_error_page' + || hasProbeErrorPageDiagnostics(errors); + if (!shouldFallbackToBackground) { + return { + ip: '', + region: '', + source: String(pageResult?.source || 'page_context_unavailable'), + }; + } + if (!allowBackgroundFallback) { + errors.push('probe:page_context:error_page_auto_fallback'); + } + } + + const backgroundResult = await detectProxyExitInfoByBackgroundFetch({ + timeoutMs, + errors, + probeEndpoints, + }); + if (!preferPageContext + && allowPageContextFallbackOnBackgroundAbort + && !backgroundResult?.ip + && Array.isArray(errors) + && errors.some((item) => /probe:bg:.*signal is aborted without reason/i.test(String(item || '')))) { + errors.push('probe:bg:abort_storm_page_fallback'); + const pageResult = await detectProxyExitInfoByPageContext({ + timeoutMs: Math.max(5000, Math.min(9000, timeoutMs)), + errors, + probeEndpoints, + }); + errors.push(`probe:bg:abort_storm_page_fallback_result:${String(pageResult?.source || 'unknown')}`); + if (pageResult?.ip) { + return pageResult; + } + } + return backgroundResult; +} + +async function pullIpProxyPoolFromApi(state = {}, options = {}) { + const apiUrl = String(state?.ipProxyApiUrl || '').trim(); + if (!apiUrl) { + throw new Error('代理 API 不能为空。'); + } + let parsedUrl; + try { + parsedUrl = new URL(apiUrl); + } catch { + throw new Error('代理 API 不是有效 URL。'); + } + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new Error('代理 API 仅支持 http/https。'); + } + + const provider = normalizeIpProxyProviderValue(state?.ipProxyService); + const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : IP_PROXY_FETCH_TIMEOUT_MS; + const response = await fetchWithTimeout(apiUrl, { + method: 'GET', + cache: 'no-store', + headers: { Accept: 'application/json, text/plain, */*' }, + }, timeoutMs); + if (!response.ok) { + throw new Error(`代理 API 请求失败(HTTP ${response.status})。`); + } + + const rawText = await response.text(); + let payload = rawText; + try { + payload = JSON.parse(rawText); + } catch { + payload = rawText; + } + const pool = normalizeIpProxyListFromPayload(payload, provider); + if (!pool.length && typeof payload === 'string') { + return normalizeProxyPoolEntries(payload, provider).slice(0, Number(options.maxItems) || 100); + } + const maxItems = Math.max(1, Math.min(500, Number(options.maxItems) || 100)); + return pool.slice(0, maxItems); +} + +function installIpProxyAuthListener() { + if (ipProxyAuthListenerInstalled) { + return; + } + if (!chrome.webRequest?.onAuthRequired?.addListener) { + return; + } + + chrome.webRequest.onAuthRequired.addListener( + (details, callback) => { + try { + const auth = currentIpProxyAuthEntry; + if (!auth?.username) { + recordIpProxyAuthChallenge(details, false); + callback(); + return; + } + const isProxyChallenge = details?.isProxy === true; + const challengerHost = String(details?.challenger?.host || '').trim().toLowerCase(); + const challengerPort = Number.parseInt(String(details?.challenger?.port || ''), 10) || 0; + const authHost = String(auth?.host || '').trim().toLowerCase(); + const authPort = Number.parseInt(String(auth?.port || ''), 10) || 0; + const challengerMatched = Boolean( + challengerHost + && authHost + && challengerHost === authHost + && (!authPort || !challengerPort || authPort === challengerPort) + ); + const proxyAuthStatus = Number.parseInt(String(details?.statusCode || ''), 10) === 407; + const shouldProvide = isProxyChallenge || challengerMatched || proxyAuthStatus; + recordIpProxyAuthChallenge(details, shouldProvide); + if (!shouldProvide) { + callback(); + return; + } + callback({ + authCredentials: { + username: auth.username, + password: String(auth.password || ''), + }, + }); + } catch { + recordIpProxyAuthChallenge(details, false); + callback(); + } + }, + { urls: [''] }, + ['asyncBlocking'] + ); + ipProxyAuthListenerInstalled = true; +} + +function installIpProxyErrorListener() { + if (ipProxyErrorListenerInstalled) { + return; + } + if (!chrome.proxy?.onProxyError?.addListener) { + return; + } + + chrome.proxy.onProxyError.addListener((details = {}) => { + ipProxyLastRuntimeError = { + error: String(details?.error || '').trim(), + details: String(details?.details || '').trim(), + fatal: Boolean(details?.fatal), + at: Date.now(), + }; + }); + + ipProxyErrorListenerInstalled = true; +} + +function callChromeProxySettings(method, details) { + const proxySettings = chrome.proxy?.settings; + if (!proxySettings || typeof proxySettings[method] !== 'function') { + return Promise.reject(new Error('当前浏览器不支持扩展代理 API')); + } + return new Promise((resolve, reject) => { + proxySettings[method](details, () => { + const lastError = chrome.runtime?.lastError; + if (lastError) { + reject(new Error(lastError.message || String(lastError))); + return; + } + resolve(true); + }); + }); +} + +function getChromeProxySettings(details = { incognito: false }) { + const proxySettings = chrome.proxy?.settings; + if (!proxySettings || typeof proxySettings.get !== 'function') { + return Promise.reject(new Error('当前浏览器不支持扩展代理 API')); + } + return new Promise((resolve, reject) => { + proxySettings.get(details, (value) => { + const lastError = chrome.runtime?.lastError; + if (lastError) { + reject(new Error(lastError.message || String(lastError))); + return; + } + resolve(value || {}); + }); + }); +} + +function validateProxyControlAfterApply(details, entry) { + const level = String(details?.levelOfControl || '').trim(); + if (level && level !== 'controlled_by_this_extension') { + return { + ok: false, + message: `代理控制权不在当前扩展(levelOfControl=${level || 'unknown'})`, + }; + } + + const mode = String(details?.value?.mode || '').trim().toLowerCase(); + if (mode !== 'pac_script') { + return { + ok: false, + message: `代理模式不是 pac_script(当前为 ${mode || 'unknown'})`, + }; + } + + const pacData = String(details?.value?.pacScript?.data || ''); + const endpoint = `${entry.host}:${entry.port}`; + if (pacData && !pacData.includes(endpoint)) { + return { + ok: false, + message: `PAC 未包含当前代理节点 ${endpoint},疑似被其他代理配置覆盖`, + }; + } + + return { + ok: true, + message: '', + }; +} + +function buildIpProxyPacScript(entry) { + const normalizedProtocol = normalizeIpProxyProtocol(entry?.protocol || DEFAULT_IP_PROXY_PROTOCOL); + const host = String(entry?.host || '').trim(); + const port = normalizeIpProxyPort(entry?.port); + if (!host || !port) { + return ''; + } + let pacScheme = 'PROXY'; + if (normalizedProtocol === 'https') { + pacScheme = 'HTTPS'; + } else if (normalizedProtocol === 'socks4') { + pacScheme = 'SOCKS4'; + } else if (normalizedProtocol === 'socks5') { + pacScheme = 'SOCKS5'; + } + const targetPatterns = IP_PROXY_TARGET_HOST_PATTERNS.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', '); + const bypassList = IP_PROXY_BYPASS_LIST.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', '); + const proxyEndpoint = `${pacScheme} ${host}:${port}`; + const routeAllLiteral = (typeof IP_PROXY_ROUTE_ALL_TRAFFIC !== 'undefined' && Boolean(IP_PROXY_ROUTE_ALL_TRAFFIC)) + ? 'true' + : 'false'; + return ` +function FindProxyForURL(url, host) { + if (!host) return "DIRECT"; + var bypassList = [${bypassList}]; + for (var i = 0; i < bypassList.length; i++) { + var bypass = bypassList[i]; + if (shExpMatch(host, bypass) || host === bypass) { + return "DIRECT"; + } + } + + var routeAllTraffic = ${routeAllLiteral}; + if (routeAllTraffic) { + return "${proxyEndpoint}"; + } + + var targets = [${targetPatterns}]; + var matched = false; + for (var j = 0; j < targets.length; j++) { + var pattern = targets[j]; + if (pattern.indexOf('*.') === 0) { + var suffix = pattern.substring(1); + var direct = pattern.substring(2); + if (dnsDomainIs(host, suffix) || host === direct) { + matched = true; + break; + } + continue; + } + if (host === pattern || dnsDomainIs(host, '.' + pattern)) { + matched = true; + break; + } + } + if (!matched) { + return "DIRECT"; + } + return "${proxyEndpoint}"; +}`.trim(); +} + +function buildIpProxyEntrySignature(entry = {}) { + return [ + normalizeIpProxyProtocol(entry?.protocol || DEFAULT_IP_PROXY_PROTOCOL), + String(entry?.host || '').trim().toLowerCase(), + String(normalizeIpProxyPort(entry?.port) || ''), + String(entry?.username || '').trim(), + String(entry?.password || ''), + ].join('|'); +} + +async function clearIpProxySettings(options = {}) { + currentIpProxyAuthEntry = null; + if (options?.resetAppliedSignature !== false) { + lastAppliedIpProxyEntrySignature = ''; + } + if (options?.resetHostVariant !== false) { + ipProxyAuthHostVariantToggle = false; + } + if (options?.resetLastAppliedAuthSnapshot === true) { + lastAppliedIpProxyAuthSnapshot = { + host: '', + port: 0, + username: '', + password: '', + }; + } + await callChromeProxySettings('clear', { scope: IP_PROXY_SETTINGS_SCOPE }); +} + +async function clearIpProxyNetworkState() { + try { + if (chrome.webRequest?.handlerBehaviorChanged) { + await new Promise((resolve) => { + try { + chrome.webRequest.handlerBehaviorChanged(() => resolve()); + } catch { + resolve(); + } + }); + } + if (chrome.browsingData?.remove) { + await chrome.browsingData.remove( + { since: 0 }, + { + cache: true, + cacheStorage: true, + serviceWorkers: true, + } + ); + } + } catch { + // ignore cache clear failures; proxy apply can still continue + } +} + +async function forceProxyConnectionDrainBeforeAuthSwitch() { + try { + await callChromeProxySettings('set', { + value: { mode: 'direct' }, + scope: IP_PROXY_SETTINGS_SCOPE, + }); + } catch { + // ignore direct mode switch failures + } + await new Promise((resolve) => setTimeout(resolve, 1200)); + await clearIpProxyNetworkState().catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 800)); +} + +async function updateIpProxyRuntimeStatus(status = {}) { + const patch = buildIpProxyRoutingStatePatch(status); + await setState(patch); + broadcastDataUpdate(patch); + return patch; +} + +async function applyIpProxySettingsFromState(state = {}, options = {}) { + const resolvedState = state || await getState(); + const enabled = Boolean(resolvedState?.ipProxyEnabled); + if (!enabled) { + try { + await clearIpProxySettings({ resetLastAppliedAuthSnapshot: true }); + } catch { + // ignore clear failures when already clear + } + await setIpProxyLeakGuardEnabled(false); + const status = { + enabled: false, + applied: false, + reason: 'disabled', + provider: normalizeIpProxyProviderValue(resolvedState?.ipProxyService), + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + error: '', + }; + await updateIpProxyRuntimeStatus(status); + return status; + } + + if (!chrome.proxy?.settings?.set) { + await setIpProxyLeakGuardEnabled(true); + const status = { + enabled: true, + applied: false, + reason: 'proxy_api_unavailable', + provider: normalizeIpProxyProviderValue(resolvedState?.ipProxyService), + error: '当前浏览器不支持扩展代理 API。', + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + }; + await updateIpProxyRuntimeStatus(status); + return status; + } + + const mode = normalizeIpProxyMode(resolvedState?.ipProxyMode); + const provider = normalizeIpProxyProviderValue(resolvedState?.ipProxyService); + if (mode === 'api' && !isApiModeProxyConfigAvailable(resolvedState)) { + const clearRuntimePatch = buildIpProxyRuntimeStatePatch(mode, { + pool: [], + index: 0, + current: null, + }, provider); + await setState(clearRuntimePatch); + broadcastDataUpdate(clearRuntimePatch); + try { + await clearIpProxySettings({ resetLastAppliedAuthSnapshot: true }); + } catch { + // ignore clear failures + } + await setIpProxyLeakGuardEnabled(true); + const status = { + enabled: true, + applied: false, + reason: 'missing_proxy_entry', + provider, + error: 'API 模式已启用,但代理 API 为空。', + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + exitSource: '', + }; + await updateIpProxyRuntimeStatus(status); + return status; + } + + const entry = getIpProxyCurrentEntryFromState(resolvedState); + if (!entry) { + try { + await clearIpProxySettings({ resetLastAppliedAuthSnapshot: true }); + } catch { + // ignore clear failures + } + await setIpProxyLeakGuardEnabled(true); + const status = { + enabled: true, + applied: false, + reason: 'missing_proxy_entry', + provider: normalizeIpProxyProviderValue(resolvedState?.ipProxyService), + error: mode === 'account' + ? getAccountListParseFailureHint(resolvedState, provider) + : '', + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + }; + await updateIpProxyRuntimeStatus(status); + return status; + } + + const explicitForceAuthRebind = Boolean(options?.forceAuthRebind); + const suppressAuthRebind = Boolean(options?.suppressAuthRebind); + const hasAccountListConfigured = mode === 'account' && hasConfiguredAccountListEntries(resolvedState); + const hasMultipleAccountEntries = mode === 'account' + && hasAccountListConfigured + && getAccountModeProxyPoolFromState(resolvedState, provider).length > 1; + const shouldForceDrain = !suppressAuthRebind && ( + explicitForceAuthRebind + || shouldForceProxyConnectionDrainForEntry(entry) + || ( + provider === '711proxy' + && hasAccountListConfigured + && Boolean(String(entry?.username || '').trim()) + ) + ); + let effectiveEntry = buildEffectiveProxyEntryForApply(entry, { + forceRotateVariant: shouldForceDrain, + allow711HostVariant: hasMultipleAccountEntries, + }); + if (shouldForceDrain) { + effectiveEntry = await maybeResolveProxyHostVariantForAuthSwitch(effectiveEntry, { + force: true, + timeoutMs: 3500, + // 仅多节点账号列表场景允许解析 IP 变体; + // 单账号显式重绑仅使用 host 字面量变体,避免解析 IP 后链路不稳定。 + allow711ResolvedIp: hasMultipleAccountEntries, + }).catch(() => effectiveEntry); + } + + const pacScript = buildIpProxyPacScript(effectiveEntry); + if (!pacScript) { + await setIpProxyLeakGuardEnabled(true); + const status = { + enabled: true, + applied: false, + reason: 'missing_proxy_entry', + host: entry.host, + port: entry.port, + region: entry.region, + hasAuth: Boolean(entry.username || entry.password), + provider: normalizeIpProxyProviderValue(entry.provider || resolvedState?.ipProxyService), + error: '代理配置不完整,无法生成 PAC 规则。', + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + }; + await updateIpProxyRuntimeStatus(status); + return status; + } + + const entrySignature = buildIpProxyEntrySignature(effectiveEntry); + const shouldResetNetworkState = Boolean( + entrySignature + && entrySignature !== lastAppliedIpProxyEntrySignature + && !options.skipExitProbe + && options.resetNetworkState !== false + ); + + try { + installIpProxyAuthListener(); + installIpProxyErrorListener(); + resetIpProxyRuntimeErrorDiagnostics(); + if (shouldForceDrain) { + if (typeof addLog === 'function') { + await addLog( + explicitForceAuthRebind + ? '正在执行代理鉴权重绑:重置代理连接并切换主机变体,避免复用旧鉴权缓存。' + : '检测到同节点切换代理账号,正在重置代理连接并切换主机变体,避免复用旧鉴权缓存。', + 'info' + ).catch(() => {}); + if (String(effectiveEntry?.host || '').trim() !== String(entry?.host || '').trim()) { + await addLog(`代理账号切换:已启用主机缓存隔离(${entry.host} -> ${effectiveEntry.host})。`, 'info').catch(() => {}); + } + } + await setIpProxyLeakGuardEnabled(true); + await forceProxyConnectionDrainBeforeAuthSwitch(); + } + currentIpProxyAuthEntry = effectiveEntry?.username + ? { + host: effectiveEntry.host, + port: effectiveEntry.port, + username: effectiveEntry.username, + password: String(effectiveEntry.password || ''), + } + : null; + await clearIpProxySettings({ + resetAppliedSignature: false, + resetHostVariant: false, + }).catch(() => {}); + currentIpProxyAuthEntry = effectiveEntry?.username + ? { + host: effectiveEntry.host, + port: effectiveEntry.port, + username: effectiveEntry.username, + password: String(effectiveEntry.password || ''), + } + : null; + if (shouldResetNetworkState) { + await clearIpProxyNetworkState(); + } + await callChromeProxySettings('set', { + value: { + mode: 'pac_script', + pacScript: { + data: pacScript, + // 官方文档建议启用 mandatory,避免 PAC 失效时静默回落到 DIRECT。 + mandatory: true, + }, + }, + scope: IP_PROXY_SETTINGS_SCOPE, + }); + const proxySettings = await getChromeProxySettings({ incognito: false }).catch(() => null); + const takeoverCheck = validateProxyControlAfterApply(proxySettings || {}, effectiveEntry); + if (!takeoverCheck.ok) { + throw new Error(takeoverCheck.message || '代理接管校验失败。'); + } + await setIpProxyLeakGuardEnabled(false); + lastAppliedIpProxyEntrySignature = entrySignature; + lastAppliedIpProxyAuthSnapshot = { + host: String(entry?.host || '').trim(), + port: normalizeIpProxyPort(entry?.port), + username: String(entry?.username || '').trim(), + password: String(entry?.password || ''), + }; + } catch (error) { + lastAppliedIpProxyEntrySignature = ''; + await setIpProxyLeakGuardEnabled(true); + const status = { + enabled: true, + applied: false, + reason: 'apply_failed', + host: entry.host, + port: entry.port, + region: entry.region, + hasAuth: Boolean(entry.username || entry.password), + provider: normalizeIpProxyProviderValue(entry.provider || resolvedState?.ipProxyService), + error: error?.message || String(error || '代理设置失败'), + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + }; + await updateIpProxyRuntimeStatus(status); + return status; + } + + const status = { + enabled: true, + applied: true, + reason: 'applied', + host: entry.host, + port: entry.port, + region: entry.region, + username: String(entry.username || '').trim(), + entrySource: resolveIpProxyAccountEntrySource(resolvedState, mode), + hasAuth: Boolean(entry.username || entry.password), + provider: normalizeIpProxyProviderValue(entry.provider || resolvedState?.ipProxyService), + error: '', + exitDetecting: !options.skipExitProbe, + exitIp: '', + exitRegion: '', + exitError: '', + exitSource: '', + }; + await updateIpProxyRuntimeStatus(status); + + if (shouldForceDrain) { + // 同节点切换账号后给网络栈一点时间建立新隧道,避免立即探测命中旧连接。 + await new Promise((resolve) => setTimeout(resolve, 900)); + } + + if (options.skipExitProbe) { + return status; + } + + const token = ++ipProxyExitDetectionToken; + const diagnostics = []; + resetIpProxyAuthDiagnostics(); + const exit = await detectProxyExitInfo({ + timeoutMs: 10000, + errors: diagnostics, + provider: status?.provider || provider, + username: status?.username || entry?.username || '', + // 优先使用页面上下文探测,确保探测链路与真实浏览器页面一致; + // 后台探测仅作为补充兜底,避免单一路径误判。 + preferPageContext: true, + allowBackgroundFallback: true, + }).catch(() => ({ ip: '', region: '' })); + if (!exit?.ip && Boolean(status?.hasAuth)) { + appendIpProxyAuthDiagnosticsToErrors(diagnostics); + } + + if (token !== ipProxyExitDetectionToken) { + return status; + } + const latest = await getState(); + if (!latest?.ipProxyEnabled) { + return status; + } + + const exitStatus = { + ...status, + exitDetecting: false, + exitIp: String(exit?.ip || '').trim(), + exitRegion: String(exit?.region || '').trim(), + exitBaselineIp: String(exit?.baselineIp || '').trim(), + exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS), + exitSource: String(exit?.source || '').trim().toLowerCase(), + authDiagnostics: status?.hasAuth ? getIpProxyAuthDiagnosticsSummary() : '', + }; + const expectedRegion = String(entry?.region || '').trim(); + let normalizedExitStatus = applyExitRegionExpectation(exitStatus, expectedRegion); + normalizedExitStatus = applyExitBaselineExpectation(normalizedExitStatus); + if (normalizedExitStatus.reason === 'connectivity_failed') { + await setIpProxyLeakGuardEnabled(true); + } + await updateIpProxyRuntimeStatus(normalizedExitStatus); + return normalizedExitStatus; +} + +function getNextIpProxyPoolIndex(currentIndex = 0, poolLength = 0, direction = 'next') { + const length = Math.max(0, Number(poolLength) || 0); + if (length <= 0) return 0; + const normalized = normalizeIpProxyCurrentIndex(currentIndex, 0) % length; + if (String(direction || '').toLowerCase() === 'prev') { + return (normalized - 1 + length) % length; + } + return (normalized + 1) % length; +} + +async function refreshIpProxyPool(options = {}) { + const state = options.state || await getState(); + const mode = normalizeIpProxyMode(options.mode || state?.ipProxyMode); + const maxItems = Math.max( + 1, + Math.min(500, Number(options.maxItems) || resolveIpProxyPoolTargetCountForMode(state, mode)) + ); + const provider = normalizeIpProxyProviderValue(state?.ipProxyService); + let pool = []; + + if (mode === 'account') { + pool = getAccountModeProxyPoolFromState(state, provider).slice(0, maxItems); + if (!pool.length) { + const parseFailureHint = getAccountListParseFailureHint(state, provider); + if (parseFailureHint) { + throw new Error(parseFailureHint); + } + throw new Error('账号密码模式没有可用代理,请先填写代理列表,或填写 Host/Port。'); + } + } else { + pool = await pullIpProxyPoolFromApi(state, { + maxItems, + timeoutMs: options.timeoutMs, + }); + if (!pool.length) { + throw new Error('代理列表为空,请检查 API 返回。'); + } + } + + const runtime = getIpProxyRuntimeSnapshot(state, mode, provider); + const summary = buildProxyPoolSummary(pool, runtime.index); + const updates = { + ipProxyService: provider, + ...buildIpProxyRuntimeStatePatch(mode, { + pool, + index: summary.index, + current: summary.current, + }, provider), + }; + await setState(updates); + broadcastDataUpdate(updates); + + let proxyRouting = null; + if (state?.ipProxyEnabled) { + const applyState = { + ...state, + ...updates, + }; + const shouldRebindSingleAccountEntry = mode === 'account' && pool.length <= 1; + proxyRouting = await applyIpProxySettingsFromState( + applyState, + shouldRebindSingleAccountEntry + ? { + forceAuthRebind: true, + suppressAuthRebind: false, + resetNetworkState: true, + skipExitProbe: false, + } + : undefined + ); + } + + return { + mode, + provider, + count: summary.count, + index: summary.index, + current: summary.current, + display: summary.display, + pool, + proxyRouting, + }; +} + +async function switchIpProxy(direction = 'next', options = {}) { + const state = options.state || await getState(); + const mode = normalizeIpProxyMode(options.mode || state?.ipProxyMode); + if (mode === 'api' && !isApiModeProxyConfigAvailable(state)) { + throw new Error('API 模式代理 URL 为空,请先填写代理 API 地址。'); + } + const maxItems = Math.max( + 1, + Math.min(500, Number(options.maxItems) || resolveIpProxyPoolTargetCountForMode(state, mode)) + ); + const provider = normalizeIpProxyProviderValue(state?.ipProxyService); + const runtime = getIpProxyRuntimeSnapshot(state, mode, provider); + let pool = []; + if (mode === 'account') { + pool = getAccountModeProxyPoolFromState(state, provider).slice(0, maxItems); + } else { + pool = runtime.pool.slice(0, maxItems); + } + + if (!pool.length) { + if (mode === 'api' && options.forceRefresh !== false) { + return refreshIpProxyPool({ + ...options, + mode, + state, + }); + } + throw new Error(mode === 'account' + ? '账号密码模式没有可切换的代理,请先填写代理列表或 Host/Port。' + : '当前没有可切换代理,请先点击“拉取”获取 IP 列表。'); + } + + const nextIndex = getNextIpProxyPoolIndex(runtime.index, pool.length, direction); + const current = pool[nextIndex]; + const updates = { + ipProxyService: provider, + ...buildIpProxyRuntimeStatePatch(mode, { + pool, + index: nextIndex, + current, + }, provider), + }; + await setState(updates); + broadcastDataUpdate(updates); + + let proxyRouting = null; + if (state?.ipProxyEnabled) { + const applyState = { + ...state, + ...updates, + }; + const shouldRebindSingleAccountEntry = mode === 'account' && pool.length <= 1; + proxyRouting = await applyIpProxySettingsFromState( + applyState, + shouldRebindSingleAccountEntry + ? { + forceAuthRebind: true, + suppressAuthRebind: false, + resetNetworkState: true, + skipExitProbe: false, + } + : undefined + ); + } + const summary = buildProxyPoolSummary(pool, nextIndex); + return { + mode, + provider, + count: summary.count, + index: summary.index, + current: summary.current, + display: summary.display, + pool, + proxyRouting, + }; +} + +async function changeIpProxyExit(options = {}) { + const state = options.state || await getState(); + if (!state?.ipProxyEnabled) { + throw new Error('请先启用 IP 代理。'); + } + + const mode = normalizeIpProxyMode(options.mode || state?.ipProxyMode); + const provider = normalizeIpProxyProviderValue(state?.ipProxyService); + if (mode !== 'account') { + throw new Error('Change 仅支持账号密码模式。'); + } + if (provider !== '711proxy') { + throw new Error('Change 当前仅支持 711Proxy。'); + } + + const entry = getIpProxyCurrentEntryFromState(state); + if (!entry || !entry.host || !entry.port) { + throw new Error('当前没有可用代理条目,无法执行 Change。'); + } + const username = String(entry?.username || '').trim(); + if (!has711SessionToken(username)) { + throw new Error('当前账号未配置 session,无法执行 Change。请先在账号中追加 session 参数。'); + } + + if (typeof addLog === 'function') { + await addLog('正在执行 Change:保持当前 session,重绑代理链路并刷新出口。', 'info').catch(() => {}); + } + + const proxyRouting = await applyIpProxySettingsFromState(state, { + forceAuthRebind: true, + suppressAuthRebind: false, + resetNetworkState: true, + skipExitProbe: false, + }); + + const runtime = getIpProxyRuntimeSnapshot(state, mode, provider); + const pool = runtime.pool.length + ? runtime.pool + : getAccountModeProxyPoolFromState(state, provider); + const summary = buildProxyPoolSummary(pool, runtime.index); + + return { + mode, + provider, + count: summary.count, + index: summary.index, + current: summary.current, + display: summary.display, + pool, + proxyRouting, + action: 'change', + }; +} + +async function tryRecoverApiProxyByRotation(options = {}) { + const maxAttempts = Math.max(1, Math.min(12, Number(options?.maxAttempts) || 4)); + let latestStatus = options?.fallbackStatus || null; + let attempts = 0; + let refreshedPool = false; + const runSingleRotationPass = async () => { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const state = await getState(); + if (!state?.ipProxyEnabled) { + break; + } + const mode = normalizeIpProxyMode(state?.ipProxyMode); + if (mode !== 'api') { + break; + } + const provider = normalizeIpProxyProviderValue(state?.ipProxyService); + const runtime = getIpProxyRuntimeSnapshot(state, mode, provider); + if (!Array.isArray(runtime?.pool) || runtime.pool.length <= 1) { + break; + } + const switched = await switchIpProxy('next', { + mode, + forceRefresh: false, + state, + }).catch(() => null); + attempts += 1; + const status = switched?.proxyRouting || null; + if (status) { + latestStatus = status; + } + if (status?.applied && status?.reason !== 'connectivity_failed') { + if (typeof addLog === 'function') { + const nodeText = Number.isInteger(switched?.index) + && Number.isInteger(switched?.count) + && switched.count > 0 + ? `(第 ${switched.index + 1}/${switched.count} 个节点)` + : ''; + const refreshText = refreshedPool ? '(已自动刷新 IP 池)' : ''; + await addLog(`IP 代理自动恢复成功:API 模式已切换到可用节点${nodeText}${refreshText}。`, 'ok').catch(() => {}); + } + return true; + } + } + return false; + }; + + const firstPassRecovered = await runSingleRotationPass(); + if (firstPassRecovered) { + return { recovered: true, attempts, status: latestStatus }; + } + + if (options?.allowRefresh !== false) { + const latestState = await getState(); + if (latestState?.ipProxyEnabled && normalizeIpProxyMode(latestState?.ipProxyMode) === 'api') { + await refreshIpProxyPool({ + mode: 'api', + state: latestState, + forceRefresh: true, + }).catch(() => null); + refreshedPool = true; + const secondPassRecovered = await runSingleRotationPass(); + if (secondPassRecovered) { + return { recovered: true, attempts, status: latestStatus }; + } + } + } + + if (latestStatus && attempts > 0) { + const suffix = refreshedPool + ? `已自动轮换 ${attempts} 个 API 节点,并刷新 IP 池后重试,仍不可用。` + : `已自动轮换 ${attempts} 个 API 节点但仍不可用。`; + const currentError = String(latestStatus.error || '').trim(); + latestStatus = { + ...latestStatus, + error: currentError + ? `${currentError} ${suffix}` + : suffix, + }; + } + + return { recovered: false, attempts, status: latestStatus }; +} + +async function probeIpProxyExit(options = {}) { + const state = options.state || await getState(); + if (!state?.ipProxyEnabled) { + const status = { + enabled: false, + applied: false, + reason: 'disabled', + provider: normalizeIpProxyProviderValue(state?.ipProxyService), + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + error: '', + }; + await updateIpProxyRuntimeStatus(status); + return { proxyRouting: status }; + } + + const mode = normalizeIpProxyMode(state?.ipProxyMode); + const provider = normalizeIpProxyProviderValue(state?.ipProxyService); + const entry = getIpProxyCurrentEntryFromState(state); + if (!entry) { + const status = { + enabled: true, + applied: false, + reason: 'missing_proxy_entry', + provider, + error: mode === 'account' + ? getAccountListParseFailureHint(state, provider) + : '', + exitDetecting: false, + exitIp: '', + exitRegion: '', + exitError: '', + }; + await updateIpProxyRuntimeStatus(status); + return { proxyRouting: status }; + } + + installIpProxyAuthListener(); + installIpProxyErrorListener(); + resetIpProxyRuntimeErrorDiagnostics(); + currentIpProxyAuthEntry = entry?.username + ? { + host: entry.host, + port: entry.port, + username: entry.username, + password: String(entry.password || ''), + } + : null; + + const probingStatus = { + enabled: true, + applied: true, + reason: String(state?.ipProxyAppliedReason || 'applied'), + host: entry.host, + port: entry.port, + region: entry.region, + username: String(entry.username || '').trim(), + entrySource: resolveIpProxyAccountEntrySource(state, mode), + hasAuth: Boolean(entry.username || entry.password), + provider: normalizeIpProxyProviderValue(entry.provider || state?.ipProxyService), + error: String(state?.ipProxyAppliedError || ''), + exitDetecting: true, + exitIp: '', + exitRegion: '', + exitError: '', + exitSource: '', + }; + // 上一轮连通性失败会启用 fail-close 规则,手动检测前先解除,避免自阻断影响探测。 + await setIpProxyLeakGuardEnabled(false); + await updateIpProxyRuntimeStatus(probingStatus); + + const runProbeRound = async (statusSeed = probingStatus, timeoutMs = Number(options?.timeoutMs) || 10000) => { + const diagnostics = []; + resetIpProxyAuthDiagnostics(); + const exit = await detectProxyExitInfo({ + timeoutMs, + errors: diagnostics, + provider: statusSeed?.provider || probingStatus?.provider || provider, + username: statusSeed?.username || probingStatus?.username || '', + // 手动探测与自动应用保持一致:先页面上下文,再后台补充。 + preferPageContext: true, + allowBackgroundFallback: true, + }).catch(() => ({ ip: '', region: '' })); + if (!exit?.ip && Boolean(statusSeed?.hasAuth)) { + appendIpProxyAuthDiagnosticsToErrors(diagnostics); + if (typeof addLog === 'function') { + await addLog(`代理出口探测失败:${buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)}`, 'warn').catch(() => {}); + } + } + const finalStatus = { + ...statusSeed, + exitDetecting: false, + exitIp: String(exit?.ip || '').trim(), + exitRegion: String(exit?.region || '').trim(), + exitBaselineIp: String(exit?.baselineIp || '').trim(), + exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS), + exitSource: String(exit?.source || '').trim().toLowerCase(), + authDiagnostics: statusSeed?.hasAuth ? getIpProxyAuthDiagnosticsSummary() : '', + }; + const expectedRegion = String(statusSeed?.region || '').trim(); + let normalized = applyExitRegionExpectation(finalStatus, expectedRegion); + normalized = applyExitBaselineExpectation(normalized); + return normalized; + }; + + let normalizedFinalStatus = await runProbeRound(probingStatus, Number(options?.timeoutMs) || 10000); + const parsedAuthSummary = parseIpProxyAuthDiagnosticsSummary(String(normalizedFinalStatus?.authDiagnostics || '')); + const missingAuthChallenge = Boolean(normalizedFinalStatus?.hasAuth) + && parsedAuthSummary + && parsedAuthSummary.challenge <= 0 + && parsedAuthSummary.provided <= 0; + const shouldRetryAuthRebind = mode === 'account' + && provider === '711proxy' + && Boolean(String(entry?.username || '').trim()) + && missingAuthChallenge + && options?.authRebindRetry !== false; + + if (shouldRetryAuthRebind) { + const maxRebindAttempts = Math.max(1, Math.min(3, Number(options?.authRebindMaxAttempts) || 2)); + for (let attempt = 0; attempt < maxRebindAttempts; attempt += 1) { + if (typeof addLog === 'function') { + const hint = attempt === 0 ? '首次重绑复测' : `第 ${attempt + 1} 次重绑复测`; + await addLog(`检测到代理链路未触发 407 挑战,正在执行${hint}。`, 'info').catch(() => {}); + } + const latestBeforeRebind = await getState(); + const reboundStatus = await applyIpProxySettingsFromState(latestBeforeRebind, { + skipExitProbe: true, + resetNetworkState: true, + forceAuthRebind: true, + }).catch(() => null); + const latestAfterRebind = await getState(); + const reboundEntry = getIpProxyCurrentEntryFromState(latestAfterRebind); + if (!reboundStatus?.applied || !reboundEntry?.host || !reboundEntry?.port) { + break; + } + currentIpProxyAuthEntry = reboundEntry?.username + ? { + host: reboundEntry.host, + port: reboundEntry.port, + username: reboundEntry.username, + password: String(reboundEntry.password || ''), + } + : null; + const retryProbingStatus = { + ...probingStatus, + reason: String(reboundStatus?.reason || probingStatus.reason || 'applied'), + host: reboundEntry.host, + port: reboundEntry.port, + region: String(reboundEntry.region || '').trim(), + username: String(reboundEntry.username || '').trim(), + entrySource: resolveIpProxyAccountEntrySource( + latestAfterRebind, + normalizeIpProxyMode(latestAfterRebind?.ipProxyMode) + ), + hasAuth: Boolean(reboundEntry.username || reboundEntry.password), + provider: normalizeIpProxyProviderValue(reboundEntry.provider || latestAfterRebind?.ipProxyService), + error: String(reboundStatus?.error || ''), + exitDetecting: true, + exitIp: '', + exitRegion: '', + exitError: '', + exitSource: '', + }; + await updateIpProxyRuntimeStatus(retryProbingStatus); + normalizedFinalStatus = await runProbeRound( + retryProbingStatus, + Math.max(9000, Number(options?.timeoutMs) || 10000) + ); + const retryAuthSummary = parseIpProxyAuthDiagnosticsSummary(String(normalizedFinalStatus?.authDiagnostics || '')); + const stillMissingChallenge = Boolean(normalizedFinalStatus?.hasAuth) + && retryAuthSummary + && retryAuthSummary.challenge <= 0 + && retryAuthSummary.provided <= 0; + if (!stillMissingChallenge) { + break; + } + } + } + if (normalizedFinalStatus.reason === 'connectivity_failed' + && normalizeIpProxyMode(state?.ipProxyMode) === 'api') { + const recovered = await tryRecoverApiProxyByRotation({ + maxAttempts: options?.autoRotateMaxAttempts, + fallbackStatus: normalizedFinalStatus, + }); + if (recovered?.status) { + normalizedFinalStatus = recovered.status; + } + } + if (normalizedFinalStatus.reason === 'connectivity_failed') { + await setIpProxyLeakGuardEnabled(true); + } + await updateIpProxyRuntimeStatus(normalizedFinalStatus); + return { proxyRouting: normalizedFinalStatus }; +} + +async function ensureIpProxySettingsAppliedFromCurrentState(options = {}) { + const state = options.state || await getState(); + return applyIpProxySettingsFromState(state, options); +} diff --git a/background/ip-proxy-provider-711proxy.js b/background/ip-proxy-provider-711proxy.js new file mode 100644 index 0000000..898bf22 --- /dev/null +++ b/background/ip-proxy-provider-711proxy.js @@ -0,0 +1,110 @@ +// background/ip-proxy-provider-711proxy.js — 711Proxy 参数与账号规则 +(function register711ProxyProvider(root) { + function normalizeCountryCode(value = '') { + const raw = String(value || '').trim().toUpperCase().replace(/[^A-Z]/g, ''); + return /^[A-Z]{2}$/.test(raw) ? raw : ''; + } + + function normalize711SessionId(value = '') { + return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64); + } + + function normalize711SessionMinutes(value = '') { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + const numeric = Number.parseInt(raw, 10); + if (!Number.isInteger(numeric)) return ''; + return String(Math.max(1, Math.min(180, numeric))); + } + + function apply711SessionToUsername(username = '', options = {}) { + const text = String(username || '').trim(); + if (!text) { + return text; + } + + const sessionId = normalize711SessionId(options?.sessionId || ''); + const sessTime = normalize711SessionMinutes(options?.sessTime || ''); + let next = text; + + if (sessionId) { + if (/(?:^|[-_])session[-_:][A-Za-z0-9_-]+?(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i.test(next)) { + next = next.replace( + /((?:^|[-_])session[-_:])([A-Za-z0-9_-]+?)(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i, + `$1${sessionId}` + ); + } else { + next = `${next}-session-${sessionId}`; + } + } + + if (sessTime) { + if (/(?:^|[-_])sessTime[-_:]?\d+\b/i.test(next)) { + next = next.replace(/((?:^|[-_])sessTime[-_:]?)(\d+)\b/i, `$1${sessTime}`); + } else { + next = `${next}-sessTime-${sessTime}`; + } + } + + return next; + } + + function apply711RegionToUsername(username = '', regionCode = '') { + const text = String(username || '').trim(); + const normalizedRegion = normalizeCountryCode(regionCode); + if (!text || !normalizedRegion) { + return text; + } + if (/(?:^|[-_])region[-_:]?[A-Za-z]{2}\b/i.test(text)) { + return text.replace(/((?:^|[-_])region[-_:]?)([A-Za-z]{2})\b/i, `$1${normalizedRegion}`); + } + return `${text}-region-${normalizedRegion}`; + } + + function transform711ProxyAccountEntry(entry = {}, context = {}) { + const state = context?.state || {}; + const hasAccountList = Boolean(context?.hasAccountList); + const nextEntry = { ...entry }; + const username = String(nextEntry.username || '').trim(); + if (!username) { + return nextEntry; + } + + const configuredRegion = normalizeCountryCode(state?.ipProxyRegion || ''); + if (!hasAccountList && configuredRegion) { + nextEntry.username = apply711RegionToUsername(username, configuredRegion); + if (!String(nextEntry.region || '').trim()) { + nextEntry.region = configuredRegion; + } + } + + // 账号列表模式按每行原样生效,不叠加固定账号区的 session/sessTime。 + if (hasAccountList) { + return nextEntry; + } + + const sessionId = normalize711SessionId(state?.ipProxyAccountSessionPrefix || ''); + const sessTime = normalize711SessionMinutes(state?.ipProxyAccountLifeMinutes || ''); + if (!sessionId && !sessTime) { + return nextEntry; + } + + nextEntry.username = apply711SessionToUsername(nextEntry.username, { + sessionId, + sessTime, + }); + return nextEntry; + } + + root.transformIpProxyAccountEntryByProvider = function transformIpProxyAccountEntryByProvider( + provider = '', + entry = {}, + context = {} + ) { + const normalizedProvider = String(provider || '').trim().toLowerCase(); + if (normalizedProvider === '711proxy') { + return transform711ProxyAccountEntry(entry, context); + } + return entry; + }; +})(typeof self !== 'undefined' ? self : globalThis); diff --git a/background/message-router.js b/background/message-router.js index 56cfa36..2c5886e 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -10,6 +10,7 @@ buildLuckmailSessionSettingsPayload, buildPersistentSettingsPayload, broadcastDataUpdate, + applyIpProxySettingsFromState, cancelScheduledAutoRun, checkIcloudSession, clearAccountRunHistory, @@ -58,6 +59,7 @@ launchAutoRunTimerPlan, listIcloudAliases, listLuckmailPurchasesForManagement, + refreshIpProxyPool, normalizeHotmailAccounts, normalizeMail2925Accounts, normalizeRunCount, @@ -69,11 +71,14 @@ pollContributionStatus, registerTab, requestStop, + probeIpProxyExit, handleCloudflareSecurityBlocked, resetState, resumeAutoRun, scheduleAutoRun, selectLuckmailPurchase, + switchIpProxy, + changeIpProxyExit, setCurrentMail2925Account, setCurrentHotmailAccount, setContributionMode, @@ -644,6 +649,34 @@ stateUpdates.currentStep = 0; } await setState(stateUpdates); + const mergedState = await getState(); + const hasIpProxyUpdates = Object.keys(updates).some((key) => key.startsWith('ipProxy')); + const hasIpProxyEnabledUpdate = Object.prototype.hasOwnProperty.call(updates, 'ipProxyEnabled'); + const previousIpProxyEnabled = Boolean(currentState?.ipProxyEnabled); + const nextIpProxyEnabled = hasIpProxyEnabledUpdate + ? Boolean(updates.ipProxyEnabled) + : previousIpProxyEnabled; + // 仅在“手动开关代理”时自动应用。 + // 其他字段改动(host/账号/地区/session 等)需由“同步/下一条/检测出口/Change”显式触发。 + const shouldApplyIpProxyOnSave = hasIpProxyUpdates + && hasIpProxyEnabledUpdate + && previousIpProxyEnabled !== nextIpProxyEnabled; + let proxyRouting = null; + if (shouldApplyIpProxyOnSave && typeof applyIpProxySettingsFromState === 'function') { + const isEnablingProxy = !previousIpProxyEnabled && nextIpProxyEnabled; + proxyRouting = await applyIpProxySettingsFromState(mergedState, { + // 手动开启时自动应用一次代理,不做出口探测; + // 出口探测由“同步/检测出口”按钮显式触发,避免开启即误判为失败。 + skipExitProbe: true, + resetNetworkState: false, + forceAuthRebind: false, + suppressAuthRebind: !isEnablingProxy, + }).catch((error) => ({ + applied: false, + reason: 'apply_failed', + error: error?.message || String(error || '代理应用失败'), + })); + } if (Boolean(currentState?.contributionMode) && typeof setContributionMode === 'function') { await setContributionMode(true); } @@ -655,7 +688,81 @@ 'info' ); } - return { ok: true, state: await getState() }; + return { ok: true, state: await getState(), proxyRouting }; + } + + case 'REFRESH_IP_PROXY_POOL': { + if (typeof refreshIpProxyPool !== 'function') { + throw new Error('IP 代理池能力尚未接入。'); + } + const result = await refreshIpProxyPool({ + maxItems: message.payload?.maxItems, + mode: message.payload?.mode, + }); + return { ok: true, ...result }; + } + + case 'SWITCH_IP_PROXY': { + if (typeof switchIpProxy !== 'function') { + throw new Error('IP 代理切换能力尚未接入。'); + } + const result = await switchIpProxy(message.payload?.direction || 'next', { + maxItems: message.payload?.maxItems, + mode: message.payload?.mode, + forceRefresh: message.payload?.forceRefresh, + }); + return { ok: true, ...result }; + } + + case 'CHANGE_IP_PROXY_EXIT': { + if (typeof changeIpProxyExit !== 'function') { + throw new Error('IP 代理 Change 能力尚未接入。'); + } + const result = await changeIpProxyExit({ + mode: message.payload?.mode, + }); + return { ok: true, ...result }; + } + + case 'PROBE_IP_PROXY_EXIT': { + if (typeof probeIpProxyExit !== 'function') { + throw new Error('IP 代理出口检测能力尚未接入。'); + } + const probeState = await getState(); + const mode = typeof normalizeIpProxyMode === 'function' + ? normalizeIpProxyMode(probeState?.ipProxyMode) + : String(probeState?.ipProxyMode || 'account').trim().toLowerCase(); + const provider = typeof normalizeIpProxyProviderValue === 'function' + ? normalizeIpProxyProviderValue(probeState?.ipProxyService) + : String(probeState?.ipProxyService || '').trim().toLowerCase(); + const is711AccountMode = mode === 'account' && provider === '711proxy'; + const previousReason = String(probeState?.ipProxyAppliedReason || '').trim().toLowerCase(); + const previousExitError = String(probeState?.ipProxyAppliedExitError || '').trim(); + const hadMissingAuthChallenge = /challenge=0|provided=0|未触发代理鉴权挑战|未收到 407/i.test(previousExitError); + const shouldPreRebindBeforeProbe = Boolean( + probeState?.ipProxyEnabled + && is711AccountMode + && (hadMissingAuthChallenge || previousReason === 'connectivity_failed') + ); + const timeoutMs = Number(message.payload?.timeoutMs) > 0 + ? Number(message.payload.timeoutMs) + : (is711AccountMode ? (shouldPreRebindBeforeProbe ? 8000 : 6000) : undefined); + + // 手动“检测出口”前先轻量应用当前配置,避免读取到旧代理链路状态。 + if (probeState?.ipProxyEnabled && typeof applyIpProxySettingsFromState === 'function') { + await applyIpProxySettingsFromState(probeState, { + skipExitProbe: true, + resetNetworkState: shouldPreRebindBeforeProbe, + forceAuthRebind: shouldPreRebindBeforeProbe, + suppressAuthRebind: !shouldPreRebindBeforeProbe, + }).catch(() => null); + } + + const result = await probeIpProxyExit({ + timeoutMs, + authRebindMaxAttempts: is711AccountMode ? 1 : undefined, + }); + return { ok: true, ...result }; } case 'EXPORT_SETTINGS': { diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index c25b2f4..0c1535f 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -8,6 +8,7 @@ addLog, chrome, CLOUDFLARE_TEMP_EMAIL_PROVIDER, + completeStepFromBackground, confirmCustomVerificationStepBypass, ensureMail2925MailboxSession, ensureIcloudMailSession, @@ -68,6 +69,68 @@ return String(value || '').trim().toLowerCase(); } + async function completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, options = {}) { + await setState({ + step8VerificationTargetEmail: '', + loginVerificationRequestedAt: null, + }); + const fromRecovery = Boolean(options.fromRecovery); + await addLog( + `步骤 ${visibleStep}:当前认证页已进入 OAuth 授权页${fromRecovery ? '(轮询失败后复核)' : ''},跳过登录验证码拉取并继续后续流程。`, + 'warn' + ); + if (typeof completeStepFromBackground === 'function') { + await completeStepFromBackground(visibleStep, { + loginVerificationRequestedAt: null, + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + }); + } + } + + function isStep8AddPhoneStateError(error) { + const message = String(error?.message || error || ''); + return /add-phone|手机号页面|手机号验证页|phone[\s-_]verification|phone\s+number/i.test(message); + } + + async function recoverStep8PollingFailure(currentState, visibleStep) { + const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); + try { + const pageState = await ensureStep8VerificationPageReady({ + visibleStep, + authLoginStep, + timeoutMs: await getStep8ReadyTimeoutMs( + '登录验证码轮询异常后复核认证页状态', + currentState?.oauthUrl || '', + visibleStep + ), + }); + if (pageState?.state === 'oauth_consent_page') { + await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true }); + return { outcome: 'completed' }; + } + if (pageState?.state === 'verification_page') { + await addLog( + `步骤 ${visibleStep}:检测到邮箱轮询/页面通信异常,但认证页仍在验证码页,先在当前链路重试,不回到步骤 ${authLoginStep}。`, + 'warn' + ); + return { outcome: 'retry_without_step7' }; + } + } catch (inspectError) { + if (isStep8RestartStep7Error(inspectError)) { + return { outcome: 'restart_step7', error: inspectError }; + } + if (isStep8AddPhoneStateError(inspectError)) { + throw inspectError; + } + await addLog( + `步骤 ${visibleStep}:轮询失败后复核认证页状态异常:${inspectError?.message || inspectError},将回到步骤 ${authLoginStep} 重试。`, + 'warn' + ); + } + return { outcome: 'restart_step7' }; + } + function getExpectedMail2925MailboxEmail(state = {}) { if (Boolean(state?.mail2925UseAccountPool)) { const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); @@ -131,6 +194,10 @@ authLoginStep: getAuthLoginStepForVisibleStep(visibleStep), timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep), }); + if (pageState?.state === 'oauth_consent_page') { + await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep); + return; + } const shouldCompareVerificationEmail = mail.provider !== '2925'; const displayedVerificationEmail = shouldCompareVerificationEmail ? normalizeStep8VerificationTargetEmail(pageState?.displayedEmail) @@ -224,24 +291,47 @@ } catch (err) { const visibleStep = getVisibleStep(currentState, 8); const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); - if (!isVerificationMailPollingError(err) && !isStep8RestartStep7Error(err)) { - throw err; + let currentError = err; + let retryWithoutStep7 = false; + const isMailPollingError = isVerificationMailPollingError(err); + if (isMailPollingError && !isStep8RestartStep7Error(err)) { + const recovery = await recoverStep8PollingFailure(currentState, visibleStep); + if (recovery?.outcome === 'completed') { + return; + } + if (recovery?.outcome === 'retry_without_step7') { + retryWithoutStep7 = true; + } + if (recovery?.error) { + currentError = recovery.error; + } + } + if (!isVerificationMailPollingError(currentError) && !isStep8RestartStep7Error(currentError)) { + throw currentError; } - lastMailPollingError = err; + lastMailPollingError = currentError; if (mailPollingAttempt >= STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS) { break; } mailPollingAttempt += 1; + if (retryWithoutStep7) { + await addLog( + `步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`, + 'warn' + ); + currentState = await getState(); + continue; + } await addLog( - isStep8RestartStep7Error(err) + isStep8RestartStep7Error(currentError) ? `步骤 ${visibleStep}:检测到认证页进入重试/超时报错状态,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...` : `步骤 ${visibleStep}:检测到邮箱轮询类失败,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...`, 'warn' ); await rerunStep7ForStep8Recovery({ - logMessage: isStep8RestartStep7Error(err) + logMessage: isStep8RestartStep7Error(currentError) ? `步骤 ${visibleStep}:认证页进入重试/超时报错状态,正在回到步骤 ${authLoginStep} 重新发起登录流程...` : `步骤 ${visibleStep}:正在回到步骤 ${authLoginStep},重新发起登录验证码流程...`, }); diff --git a/background/verification-flow.js b/background/verification-flow.js index 229bed7..7e8ea92 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -129,6 +129,86 @@ }; } + async function detectStep8PostSubmitFallback(options = {}) { + const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 9000); + const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || 300); + const step = Number(options.step) || 8; + const startedAt = Date.now(); + let lastSnapshot = null; + + while (Date.now() - startedAt < timeoutMs) { + throwIfStopped(); + try { + const request = { + type: 'GET_LOGIN_AUTH_STATE', + source: 'background', + payload: {}, + }; + const requestTimeoutMs = Math.max(1200, Math.min(5000, timeoutMs)); + const result = typeof sendToContentScriptResilient === 'function' + ? await sendToContentScriptResilient( + 'signup-page', + request, + { + timeoutMs: requestTimeoutMs, + responseTimeoutMs: requestTimeoutMs, + retryDelayMs: 400, + logMessage: `步骤 ${step}:验证码提交后页面正在切换,等待页面恢复并确认授权状态...`, + } + ) + : await sendToContentScript('signup-page', request, { + responseTimeoutMs: requestTimeoutMs, + }); + + if (result?.error) { + throw new Error(result.error); + } + + const authState = String(result?.state || '').trim(); + const authUrl = String(result?.url || '').trim(); + lastSnapshot = { + state: authState || 'unknown', + url: authUrl, + }; + + if (authState === 'oauth_consent_page') { + return { + success: true, + reason: 'oauth_consent_page', + addPhonePage: false, + url: authUrl, + }; + } + if (authState === 'add_phone_page' || authState === 'phone_verification_page') { + return { + success: true, + reason: 'add_phone_page', + addPhonePage: true, + url: authUrl || 'https://auth.openai.com/add-phone', + }; + } + if (authState === 'login_timeout_error_page') { + return { + success: false, + reason: 'login_timeout_error_page', + restartStep7: true, + url: authUrl, + }; + } + } catch (_) { + // Ignore transient inspect failures and keep polling. + } + + await sleepWithStop(pollIntervalMs); + } + + return { + success: false, + reason: 'unknown', + snapshot: lastSnapshot, + }; + } + function getVerificationResendStateKey() { return 'verificationResendCount'; } @@ -772,6 +852,31 @@ }; } } + if (step === 8 && isRetryableVerificationTransportError(err)) { + const fallback = await detectStep8PostSubmitFallback({ + step, + timeoutMs: 9000, + pollIntervalMs: 300, + }); + if (fallback.success) { + if (fallback.addPhonePage) { + await addLog('步骤 8:验证码提交后通信中断,但页面已进入手机号验证页,按提交成功继续。', 'warn'); + } else { + await addLog('步骤 8:验证码提交后通信中断,但页面已进入 OAuth 授权页,按提交成功继续。', 'warn'); + } + return { + success: true, + assumed: true, + transportRecovered: true, + addPhonePage: Boolean(fallback.addPhonePage), + url: fallback.url || '', + }; + } + if (fallback.restartStep7) { + const urlPart = fallback.url ? ` URL: ${fallback.url}` : ''; + throw new Error(`STEP8_RESTART_STEP7::步骤 8:验证码提交后认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim()); + } + } throw err; } } else { diff --git a/docs/ip-proxy-module.md b/docs/ip-proxy-module.md new file mode 100644 index 0000000..53e2794 --- /dev/null +++ b/docs/ip-proxy-module.md @@ -0,0 +1,131 @@ +# IP Proxy 模块说明(Ultra1 集成版) + +## 1. 模块定位 + +`IP Proxy` 是扩展内的网络出口接管模块,用于把自动化流程的页面访问链路切到指定代理节点,并在侧边栏内可视化展示当前代理与出口状态。 + +本模块目标是解决三件事: + +1. 在扩展内统一配置和切换代理,而不是依赖浏览器外部全局代理。 +2. 对“代理已配置但未真正接管/未真正出站”的情况进行显式诊断。 +3. 在自动化流程中可控地切换出口,降低单节点波动影响。 + +## 2. 代码结构 + +### 2.1 Background(核心逻辑) + +- `background/ip-proxy-core.js` + - 代理池解析与运行态管理 + - 应用/清除代理配置 + - 出口探测与状态诊断 + - `SYNC/NEXT/CHANGE/PROBE` 主流程 +- `background/ip-proxy-provider-711proxy.js` + - Provider 级别参数处理(当前重点是 711 账号串 token 规则) +- `background/message-router.js` + - 暴露消息接口: + - `REFRESH_IP_PROXY_POOL` + - `SWITCH_IP_PROXY` + - `CHANGE_IP_PROXY_EXIT` + - `PROBE_IP_PROXY_EXIT` +- `background.js` + - 持久化字段定义与默认值 + - 启动恢复时的代理状态接管 + - 自动运行成功后的代理切换钩子 + +### 2.2 Sidepanel(界面与交互) + +- `sidepanel/ip-proxy-panel.js` + - 代理 UI 状态渲染 + - 按钮行为(同步/下一条/Change/检测出口) + - 运行态文案和诊断详情展示 + - 711 账号参数双向同步(`session/sessTime/region`) +- `sidepanel/ip-proxy-provider-711proxy.js` + - Provider 级输入辅助(地区推断) +- `sidepanel/sidepanel.html` + - 代理区块 UI +- `sidepanel/sidepanel.css` + - 代理区块样式 +- `sidepanel/sidepanel.js` + - 与设置保存流整合、事件绑定 + +## 3. 当前功能范围(本 PR) + +### 3.1 基础能力 + +1. 启用/禁用代理接管(PAC + 认证回填)。 +2. 固定账号模式(Host/Port/Username/Password/Region)。 +3. 出口探测与状态卡(当前代理、当前出口、诊断详情)。 +4. 四个动作按钮: + - `同步`:应用当前配置并刷新运行态 + - `下一条`:切到下一个可用节点 + - `Change`:保持 session 的前提下重绑并换出口(711) + - `检测出口`:只做出口复测,不改节点 +5. `检查IP`:打开 `https://ipinfo.io/what-is-my-ip` + +### 3.2 711 账号串参数联动 + +支持从用户名中识别并回填: + +- `session-xxxx` +- `sessTime-xx`(兼容 `life-xx`) +- `region-XX` + +支持从表单回写到用户名: + +- 修改会话值 -> 更新 `session-*` +- 修改时长 -> 更新 `sessTime-*` +- 修改地区 -> 更新 `region-*` + +### 3.3 同步后的自动复测 + +为避免“同步后还要手动点检测出口”: + +- `同步/下一条/Change` 执行完成后,自动追加一次静默 `检测出口`。 + +## 4. 当前发布策略(为了稳定) + +本 PR 是“先可用,再扩展”的第一阶段: + +1. 服务商主路径按 `711` 优先。 +2. `API 模式`在 UI 中保留但禁用(暂未开放)。 +3. `账号列表模式`目前关闭(防止多条目与鉴权缓存复用带来的不稳定)。 + +对应开关位于: + +- `sidepanel/sidepanel.js`:`IP_PROXY_API_MODE_ENABLED = false` +- `sidepanel/sidepanel.js`:`IP_PROXY_ACCOUNT_LIST_ENABLED = false` +- `background.js`:`IP_PROXY_ACCOUNT_LIST_ENABLED = false` + +## 5. 使用方式(操作步骤) + +1. 打开侧边栏 `IP代理` 开关。 +2. 选择账号模式,填写: + - Host + - Port + - Username + - Password + - Region(可选) +3. 点击 `同步`。 +4. 查看状态卡: + - 当前代理 + - 当前出口 + - 是否有校验提示 +5. 需要换出口时: + - 711 + session 场景优先用 `Change` + - 或使用 `下一条` +6. 需要手动复核时点击 `检测出口` 或 `检查IP`。 + +## 6. 已知限制 + +1. 某些代理链路不会返回标准 `407`,扩展无法触发认证回填,这类链路可能不稳定。 +2. 地区“期望值”和“实际出口”不一致时,模块会保留接管并提示校验告警(不直接判定全失败)。 +3. 不同探测源(页面探测/后台兜底)在网络波动时可能短时出现差异,状态卡会显示来源与诊断。 + +## 7. 回归建议 + +建议至少覆盖以下场景: + +1. 固定账号:启用 -> 同步 -> 出口检测成功。 +2. 固定账号:`session/sessTime/region` 双向同步正确。 +3. 固定账号:`同步/下一条/Change` 后无需手动点检测即可刷新出口状态。 +4. 非标准链路失败时,诊断信息可读且不误报“已接管成功”。 diff --git a/manifest.json b/manifest.json index e562708..f1e2895 100644 --- a/manifest.json +++ b/manifest.json @@ -9,6 +9,9 @@ "alarms", "tabs", "webNavigation", + "webRequest", + "webRequestAuthProvider", + "proxy", "declarativeNetRequest", "debugger", "browsingData", diff --git a/sidepanel/ip-proxy-panel.js b/sidepanel/ip-proxy-panel.js new file mode 100644 index 0000000..fe6e115 --- /dev/null +++ b/sidepanel/ip-proxy-panel.js @@ -0,0 +1,1536 @@ +// sidepanel/ip-proxy-panel.js — IP代理面板(轻量解耦) +function normalizeIpProxyService(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + const enabledServices = Array.isArray(globalThis.IP_PROXY_ENABLED_SERVICES) + ? globalThis.IP_PROXY_ENABLED_SERVICES + : [DEFAULT_IP_PROXY_SERVICE]; + if (enabledServices.includes(normalized)) { + return normalized; + } + return DEFAULT_IP_PROXY_SERVICE; +} + +const ipProxyActionState = { + busy: false, + action: '', +}; + +function normalizeIpProxyActionType(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized || 'action'; +} + +function getIpProxyActionLabel(action = '') { + const normalized = normalizeIpProxyActionType(action); + if (normalized === 'refresh') return '同步代理'; + if (normalized === 'next') return '切换代理'; + if (normalized === 'change') return '会话换出口'; + if (normalized === 'probe') return '检测出口'; + return '代理操作'; +} + +function isIpProxyActionBusy() { + return Boolean(ipProxyActionState.busy); +} + +function getIpProxyActionState() { + return { + busy: Boolean(ipProxyActionState.busy), + action: normalizeIpProxyActionType(ipProxyActionState.action), + }; +} + +function setIpProxyActionBusy(action = '', busy = false) { + ipProxyActionState.busy = Boolean(busy); + ipProxyActionState.action = ipProxyActionState.busy ? normalizeIpProxyActionType(action) : ''; +} + +async function runIpProxyActionWithLock(action = '', runner) { + if (typeof runner !== 'function') { + throw new Error('代理操作执行器无效。'); + } + const nextAction = normalizeIpProxyActionType(action); + const currentState = getIpProxyActionState(); + if (currentState.busy) { + if (typeof showToast === 'function') { + showToast(`${getIpProxyActionLabel(currentState.action)}进行中,请稍候。`, 'info', 1600); + } + return { skipped: true }; + } + + setIpProxyActionBusy(nextAction, true); + if (typeof updateIpProxyUI === 'function') { + updateIpProxyUI(latestState); + } + + try { + const value = await runner(); + return { skipped: false, value }; + } finally { + setIpProxyActionBusy(nextAction, false); + if (typeof updateIpProxyUI === 'function') { + updateIpProxyUI(latestState); + } + } +} + +function normalizeIpProxyMode(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return SUPPORTED_IP_PROXY_MODES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_MODE; +} + +function isIpProxyApiModeAvailable() { + return Boolean(typeof IP_PROXY_API_MODE_ENABLED === 'undefined' + ? false + : IP_PROXY_API_MODE_ENABLED); +} + +function isIpProxyAccountListAvailable() { + return Boolean(typeof IP_PROXY_ACCOUNT_LIST_ENABLED === 'undefined' + ? true + : IP_PROXY_ACCOUNT_LIST_ENABLED); +} + +function normalizeIpProxyModeForCurrentRelease(value = '') { + const normalized = normalizeIpProxyMode(value); + if (!isIpProxyApiModeAvailable() && normalized === 'api') { + return 'account'; + } + return normalized; +} + +function normalizeIpProxyProtocol(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return SUPPORTED_IP_PROXY_PROTOCOLS.includes(normalized) ? normalized : DEFAULT_IP_PROXY_PROTOCOL; +} + +function normalizeIpProxyPort(value = '') { + const numeric = Number.parseInt(String(value || '').trim(), 10); + if (!Number.isInteger(numeric) || numeric <= 0 || numeric > 65535) { + return 0; + } + return numeric; +} + +function normalizeIpProxyPoolTargetCount(value = '', fallback = 20) { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return String(Math.max(1, Math.min(500, Number(fallback) || 20))); + } + const numeric = Number.parseInt(rawValue, 10); + if (!Number.isInteger(numeric)) { + return String(Math.max(1, Math.min(500, Number(fallback) || 20))); + } + return String(Math.max(1, Math.min(500, numeric))); +} + +function normalizeIpProxyAccountLifeMinutes(value = '', fallback = '') { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return String(fallback || '').trim(); + } + const numeric = Number.parseInt(rawValue, 10); + if (!Number.isInteger(numeric)) { + return String(fallback || '').trim(); + } + return String(Math.max(1, Math.min(1440, numeric))); +} + +function normalizeIpProxyAccountSessionPrefix(value = '') { + return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32); +} + +function normalizeIpProxyAccountList(value = '') { + return String(value || '') + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => { + if (!line) { + return false; + } + // 与后台保持一致:支持 # / // / ; 注释行,注释行不参与生效。 + if (/^(?:#|\/\/|;)/.test(line)) { + return false; + } + return true; + }) + .join('\n'); +} + +function normalizeIpProxyServiceProfile(rawValue = {}) { + const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) + ? rawValue + : {}; + return { + mode: normalizeIpProxyModeForCurrentRelease(raw.mode), + apiUrl: String(raw.apiUrl || '').trim(), + accountList: normalizeIpProxyAccountList(raw.accountList || ''), + accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''), + accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''), + poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20), + host: String(raw.host || '').trim(), + port: String(normalizeIpProxyPort(raw.port || '') || ''), + protocol: normalizeIpProxyProtocol(raw.protocol), + username: String(raw.username || '').trim(), + password: String(raw.password || ''), + region: String(raw.region || '').trim(), + }; +} + +function buildIpProxyServiceProfileFromFlatState(state = {}) { + return normalizeIpProxyServiceProfile({ + mode: state?.ipProxyMode, + apiUrl: state?.ipProxyApiUrl, + accountList: state?.ipProxyAccountList, + accountSessionPrefix: state?.ipProxyAccountSessionPrefix, + accountLifeMinutes: state?.ipProxyAccountLifeMinutes, + poolTargetCount: state?.ipProxyPoolTargetCount, + host: state?.ipProxyHost, + port: state?.ipProxyPort, + protocol: state?.ipProxyProtocol, + username: state?.ipProxyUsername, + password: state?.ipProxyPassword, + region: state?.ipProxyRegion, + }); +} + +function normalizeIpProxyServiceProfiles(rawValue = {}, fallbackState = {}) { + const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) + ? rawValue + : {}; + const fallbackProfile = buildIpProxyServiceProfileFromFlatState(fallbackState); + const result = {}; + SUPPORTED_IP_PROXY_SERVICES.forEach((service) => { + const serviceRaw = raw[service]; + if (serviceRaw && typeof serviceRaw === 'object' && !Array.isArray(serviceRaw)) { + result[service] = normalizeIpProxyServiceProfile(serviceRaw); + return; + } + result[service] = normalizeIpProxyServiceProfile(fallbackProfile); + }); + return result; +} + +function getIpProxyServiceProfilesFromState(state = latestState) { + return normalizeIpProxyServiceProfiles(state?.ipProxyServiceProfiles || {}, state || {}); +} + +function getIpProxyServiceProfile(service = '', state = latestState) { + const selectedService = normalizeIpProxyService(service || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE); + const profiles = getIpProxyServiceProfilesFromState(state); + const profile = normalizeIpProxyServiceProfile(profiles[selectedService] || {}); + const resolvedRegion = resolveIpProxyRegionFromInputs({ + service: selectedService, + mode: profile.mode, + host: profile.host, + username: profile.username, + region: profile.region, + }); + if (resolvedRegion) { + profile.region = resolvedRegion; + } + return profile; +} + +function inferRegionFromProxyUsernameForPanel(username = '') { + const text = String(username || '').trim(); + if (!text) return ''; + const match = text.match(/(?:^|[-_])(?:region|area|country)[-_:]?([A-Za-z]{2})\b/i); + if (!match) return ''; + return String(match[1] || '').trim().toUpperCase(); +} + +function inferRegionFromProxyHostForPanel(host = '') { + const text = String(host || '').trim().toLowerCase().replace(/\.$/, ''); + if (!text || !text.includes('.')) return ''; + const firstLabel = String(text.split('.')[0] || '').trim(); + if (!/^[a-z]{2}$/.test(firstLabel)) return ''; + return firstLabel.toUpperCase(); +} + +function normalizeExplicitRegionForPanel(region = '') { + const text = String(region || '').trim().toUpperCase().replace(/[^A-Z]/g, ''); + return /^[A-Z]{2}$/.test(text) ? text : ''; +} + +function resolveIpProxyRegionFromInputs(options = {}) { + const selectedService = normalizeIpProxyService(options?.service || DEFAULT_IP_PROXY_SERVICE); + const selectedMode = normalizeIpProxyModeForCurrentRelease(options?.mode || DEFAULT_IP_PROXY_MODE); + if (selectedMode !== 'account') { + return ''; + } + + const host = String(options?.host || '').trim(); + const username = String(options?.username || '').trim(); + const region = String(options?.region || '').trim(); + if (selectedService === '711proxy') { + const resolvedCode = typeof resolve711ProxyRegionFromInputs === 'function' + ? resolve711ProxyRegionFromInputs({ host, username, region }) + : ''; + if (resolvedCode) { + return String(resolvedCode || '').trim().toUpperCase(); + } + } + + const fromUsername = inferRegionFromProxyUsernameForPanel(username); + if (fromUsername) { + return fromUsername; + } + const fromHost = inferRegionFromProxyHostForPanel(host); + if (fromHost) { + return fromHost; + } + return normalizeExplicitRegionForPanel(region); +} + +function hasCurrentInputAccountListEntries() { + if (!isIpProxyAccountListAvailable()) { + return false; + } + return normalizeIpProxyAccountList(inputIpProxyAccountList?.value || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .length > 0; +} + +function syncIpProxyRegionInputFromCredentials(options = {}) { + if (!inputIpProxyRegion) { + return ''; + } + const selectedService = normalizeIpProxyService( + selectIpProxyService?.value || latestState?.ipProxyService || DEFAULT_IP_PROXY_SERVICE + ); + const selectedMode = normalizeIpProxyModeForCurrentRelease(getSelectedIpProxyMode()); + if (selectedMode !== 'account' || hasCurrentInputAccountListEntries()) { + return ''; + } + const force = Boolean(options?.force); + const currentRegion = String(inputIpProxyRegion.value || '').trim(); + const resolvedRegion = resolveIpProxyRegionFromInputs({ + service: selectedService, + mode: selectedMode, + host: inputIpProxyHost?.value || '', + username: inputIpProxyUsername?.value || '', + region: currentRegion, + }); + if (!resolvedRegion) { + return ''; + } + const normalizedCurrent = normalizeExplicitRegionForPanel(currentRegion); + if (force || !normalizedCurrent) { + inputIpProxyRegion.value = resolvedRegion; + } + return resolvedRegion; +} + +function buildCurrentIpProxyServiceProfileFromInputs() { + const selectedService = normalizeIpProxyService( + selectIpProxyService?.value || latestState?.ipProxyService || DEFAULT_IP_PROXY_SERVICE + ); + const selectedMode = normalizeIpProxyMode(getSelectedIpProxyMode()); + const effectiveMode = normalizeIpProxyModeForCurrentRelease(selectedMode); + const rawRegion = String(inputIpProxyRegion?.value || '').trim(); + const finalRegion = resolveIpProxyRegionFromInputs({ + service: selectedService, + mode: effectiveMode, + host: inputIpProxyHost?.value || '', + username: inputIpProxyUsername?.value || '', + region: rawRegion, + }) || rawRegion; + return normalizeIpProxyServiceProfile({ + mode: effectiveMode, + apiUrl: inputIpProxyApiUrl?.value || '', + accountList: isIpProxyAccountListAvailable() ? (inputIpProxyAccountList?.value || '') : '', + accountSessionPrefix: inputIpProxyAccountSessionPrefix?.value || '', + accountLifeMinutes: inputIpProxyAccountLifeMinutes?.value || '', + poolTargetCount: inputIpProxyPoolTargetCount?.value || '', + host: inputIpProxyHost?.value || '', + port: inputIpProxyPort?.value || '', + protocol: selectIpProxyProtocol?.value || '', + username: inputIpProxyUsername?.value || '', + password: inputIpProxyPassword?.value || '', + region: finalRegion, + }); +} + +function buildIpProxyServiceProfilesPatch(selectedService = '', state = latestState) { + const nextService = normalizeIpProxyService( + selectedService + || selectIpProxyService?.value + || state?.ipProxyService + || DEFAULT_IP_PROXY_SERVICE + ); + const profiles = getIpProxyServiceProfilesFromState(state || {}); + profiles[nextService] = buildCurrentIpProxyServiceProfileFromInputs(); + return normalizeIpProxyServiceProfiles(profiles, state || {}); +} + +function buildIpProxyStatePatchFromServiceProfile(service = '', profile = {}) { + const normalizedService = normalizeIpProxyService(service || DEFAULT_IP_PROXY_SERVICE); + const normalizedProfile = normalizeIpProxyServiceProfile(profile); + return { + ipProxyService: normalizedService, + ipProxyMode: normalizedProfile.mode, + ipProxyApiUrl: normalizedProfile.apiUrl, + ipProxyAccountList: normalizedProfile.accountList, + ipProxyAccountSessionPrefix: normalizedProfile.accountSessionPrefix, + ipProxyAccountLifeMinutes: normalizedProfile.accountLifeMinutes, + ipProxyPoolTargetCount: normalizedProfile.poolTargetCount, + ipProxyHost: normalizedProfile.host, + ipProxyPort: normalizedProfile.port, + ipProxyProtocol: normalizedProfile.protocol, + ipProxyUsername: normalizedProfile.username, + ipProxyPassword: normalizedProfile.password, + ipProxyRegion: normalizedProfile.region, + }; +} + +function applyIpProxyServiceProfileToInputs(profile = {}, options = {}) { + const { keepMode = false } = options; + const normalizedProfile = normalizeIpProxyServiceProfile(profile); + if (!keepMode) { + setIpProxyMode(normalizedProfile.mode); + } + if (inputIpProxyApiUrl) { + inputIpProxyApiUrl.value = normalizedProfile.apiUrl; + } + if (inputIpProxyAccountList) { + inputIpProxyAccountList.value = normalizedProfile.accountList; + } + if (inputIpProxyAccountSessionPrefix) { + inputIpProxyAccountSessionPrefix.value = normalizedProfile.accountSessionPrefix; + } + if (inputIpProxyAccountLifeMinutes) { + inputIpProxyAccountLifeMinutes.value = normalizedProfile.accountLifeMinutes; + } + if (inputIpProxyPoolTargetCount) { + inputIpProxyPoolTargetCount.value = normalizedProfile.poolTargetCount; + } + if (inputIpProxyHost) { + inputIpProxyHost.value = normalizedProfile.host; + } + if (inputIpProxyPort) { + inputIpProxyPort.value = normalizedProfile.port; + } + if (selectIpProxyProtocol) { + selectIpProxyProtocol.value = normalizedProfile.protocol; + } + if (inputIpProxyUsername) { + inputIpProxyUsername.value = normalizedProfile.username; + } + if (inputIpProxyPassword) { + inputIpProxyPassword.value = normalizedProfile.password; + } + if (inputIpProxyRegion) { + inputIpProxyRegion.value = normalizedProfile.region; + } +} + +function getSelectedIpProxyEnabled() { + if (inputIpProxyEnabled) { + return Boolean(inputIpProxyEnabled.checked); + } + const activeButton = ipProxyEnabledButtons.find((button) => button.classList.contains('is-active')); + return String(activeButton?.dataset?.ipProxyEnabled) === 'true'; +} + +function setIpProxyEnabled(enabled) { + const nextEnabled = Boolean(enabled); + if (inputIpProxyEnabled) { + inputIpProxyEnabled.checked = nextEnabled; + } + ipProxyEnabledButtons.forEach((button) => { + const buttonValue = String(button?.dataset?.ipProxyEnabled) === 'true'; + button.classList.toggle('is-active', buttonValue === nextEnabled); + button.setAttribute('aria-pressed', String(buttonValue === nextEnabled)); + }); +} + +function getSelectedIpProxyMode() { + const activeButton = ipProxyModeButtons.find((button) => button.classList.contains('is-active')); + return normalizeIpProxyModeForCurrentRelease(activeButton?.dataset?.ipProxyMode || DEFAULT_IP_PROXY_MODE); +} + +function setIpProxyMode(mode) { + const nextMode = normalizeIpProxyModeForCurrentRelease(mode); + ipProxyModeButtons.forEach((button) => { + const buttonMode = normalizeIpProxyMode(button?.dataset?.ipProxyMode || DEFAULT_IP_PROXY_MODE); + button.classList.toggle('is-active', buttonMode === nextMode); + button.setAttribute('aria-pressed', String(buttonMode === nextMode)); + }); +} + +function getIpProxyRuntimeFieldNames(mode = DEFAULT_IP_PROXY_MODE) { + const normalizedMode = normalizeIpProxyModeForCurrentRelease(mode); + if (normalizedMode === 'account') { + return { + poolKey: 'ipProxyAccountPool', + indexKey: 'ipProxyAccountCurrentIndex', + currentKey: 'ipProxyAccountCurrent', + }; + } + return { + poolKey: 'ipProxyApiPool', + indexKey: 'ipProxyApiCurrentIndex', + currentKey: 'ipProxyApiCurrent', + }; +} + +function getIpProxyRuntimeSnapshot(state = latestState, mode = normalizeIpProxyModeForCurrentRelease(state?.ipProxyMode)) { + const normalizedMode = normalizeIpProxyModeForCurrentRelease(mode); + const fields = getIpProxyRuntimeFieldNames(normalizedMode); + const hasAccountListConfigured = normalizedMode === 'account' + && normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean).length > 0; + const hasModePool = Array.isArray(state?.[fields.poolKey]); + const hasModeCurrent = state?.[fields.currentKey] !== undefined && state?.[fields.currentKey] !== null; + const hasModeIndex = state?.[fields.indexKey] !== undefined && state?.[fields.indexKey] !== null; + const modePool = Array.isArray(state?.[fields.poolKey]) ? state[fields.poolKey] : []; + // 账号模式在“固定账号”场景下不应读取历史 runtime 缓存,否则会出现 + // “状态显示 A 节点、当前节点显示 B 节点”的错位。 + const allowAccountRuntimeCache = normalizedMode !== 'account' || hasAccountListConfigured; + const pool = hasModePool && allowAccountRuntimeCache ? modePool : []; + const current = hasModeCurrent && allowAccountRuntimeCache ? state?.[fields.currentKey] : null; + const rawIndex = hasModeIndex && allowAccountRuntimeCache ? state?.[fields.indexKey] : 0; + const index = Number.isFinite(Number(rawIndex)) ? Math.max(0, Math.floor(Number(rawIndex))) : 0; + return { + mode: normalizedMode, + ...fields, + hasModePool: hasModePool && allowAccountRuntimeCache, + hasModeCurrent: hasModeCurrent && allowAccountRuntimeCache, + hasModeIndex: hasModeIndex && allowAccountRuntimeCache, + hasAccountListConfigured, + pool, + current, + index, + }; +} + +function buildIpProxyRuntimeStatePatchForMode(mode = DEFAULT_IP_PROXY_MODE, response = {}) { + const normalizedMode = normalizeIpProxyModeForCurrentRelease(mode); + const fields = getIpProxyRuntimeFieldNames(normalizedMode); + const patch = {}; + if (response?.pool !== undefined) { + patch[fields.poolKey] = Array.isArray(response.pool) ? response.pool : []; + // 兼容旧字段:始终反映当前激活模式的运行态。 + patch.ipProxyPool = patch[fields.poolKey]; + } + if (response?.index !== undefined) { + const index = Number.isFinite(Number(response.index)) ? Math.max(0, Math.floor(Number(response.index))) : 0; + patch[fields.indexKey] = index; + patch.ipProxyCurrentIndex = index; + } + if (response?.current !== undefined) { + patch[fields.currentKey] = response.current || null; + patch.ipProxyCurrent = response.current || null; + } + return patch; +} + +function getIpProxyCurrentEntry(state = latestState) { + const mode = normalizeIpProxyModeForCurrentRelease(state?.ipProxyMode); + if (mode === 'account') { + const runtime = getIpProxyRuntimeSnapshot(state, mode); + const poolEntry = runtime.hasModePool && runtime.pool.length + ? runtime.pool[runtime.index % runtime.pool.length] + : null; + const modeCurrent = runtime.hasModeCurrent ? runtime.current : null; + const hasRuntimeEntry = Boolean(poolEntry || modeCurrent); + const current = poolEntry || modeCurrent || {}; + const host = String(current?.host || state?.ipProxyHost || '').trim(); + const port = Number(current?.port || normalizeIpProxyPort(state?.ipProxyPort)); + if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) { + return null; + } + return { + host, + port, + username: hasRuntimeEntry + ? String(current?.username || '').trim() + : String(current?.username || state?.ipProxyUsername || '').trim(), + password: hasRuntimeEntry + ? String(current?.password || '') + : String(current?.password || state?.ipProxyPassword || ''), + protocol: normalizeIpProxyProtocol(current?.protocol || state?.ipProxyProtocol), + region: hasRuntimeEntry + ? String(current?.region || '').trim() + : String(current?.region || state?.ipProxyRegion || '').trim(), + provider: normalizeIpProxyService(state?.ipProxyService), + }; + } + + const runtime = getIpProxyRuntimeSnapshot(state, mode); + if (runtime.pool.length) { + const index = runtime.index % runtime.pool.length; + const current = runtime.pool[index]; + if (current?.host && current?.port) { + return current; + } + } + const current = runtime.current; + return current && current.host && current.port ? current : null; +} + +function has711SessionTokenForPanel(username = '') { + const text = String(username || '').trim(); + if (!text) { + return false; + } + return /(?:^|[-_])session[-_:][A-Za-z0-9_-]+?(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i.test(text); +} + +function normalize711SessionIdForPanel(value = '') { + return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64); +} + +function normalize711SessTimeForPanel(value = '') { + const raw = String(value ?? '').trim(); + if (!raw) { + return ''; + } + const numeric = Number.parseInt(raw, 10); + if (!Number.isInteger(numeric)) { + return ''; + } + return String(Math.max(1, Math.min(180, numeric))); +} + +function normalize711RegionCodeForPanel(value = '') { + const text = String(value || '').trim().toUpperCase().replace(/[^A-Z]/g, ''); + return /^[A-Z]{2}$/.test(text) ? text : ''; +} + +function shouldAutoSync711SessionFieldsForPanel(state = latestState) { + const service = normalizeIpProxyService( + selectIpProxyService?.value || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE + ); + const mode = normalizeIpProxyModeForCurrentRelease(getSelectedIpProxyMode()); + if (service !== '711proxy' || mode !== 'account') { + return false; + } + return !hasCurrentInputAccountListEntries(); +} + +function parse711RegionFromUsernameForPanel(username = '') { + const text = String(username || '').trim(); + if (!text) { + return ''; + } + const match = text.match(/(?:^|[-_])region[-_:]?([A-Za-z]{2})\b/i); + return normalize711RegionCodeForPanel(match ? match[1] : ''); +} + +function apply711RegionToUsernameForPanel(username = '', regionCode = '', options = {}) { + const text = String(username || '').trim(); + if (!text) { + return ''; + } + + const normalizedRegion = normalize711RegionCodeForPanel(regionCode); + const removeWhenEmpty = Boolean(options?.removeWhenEmpty); + + if (normalizedRegion) { + if (/(?:^|[-_])region[-_:]?[A-Za-z]{2}\b/i.test(text)) { + return text.replace(/((?:^|[-_])region[-_:]?)([A-Za-z]{2})\b/i, `$1${normalizedRegion}`); + } + return `${text}-region-${normalizedRegion}`; + } + + if (!removeWhenEmpty) { + return text; + } + + return String(text.replace(/(^|[-_])region[-_:]?[A-Za-z]{2}\b/ig, '$1') || '') + .replace(/[-_]{2,}/g, '-') + .replace(/^[-_]+|[-_]+$/g, '') + .trim(); +} + +function parse711SessionConfigFromUsernameForPanel(username = '') { + const text = String(username || '').trim(); + if (!text) { + return { sessionId: '', sessTime: '' }; + } + const sessionMatch = text.match(/(?:^|[-_])session[-_:]([A-Za-z0-9_-]+?)(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i); + const sessTimeMatch = text.match(/(?:^|[-_])sessTime[-_:]?(\d+)\b/i); + const lifeMatch = text.match(/(?:^|[-_])life[-_:]?(\d+)\b/i); + return { + sessionId: normalize711SessionIdForPanel(sessionMatch ? sessionMatch[1] : ''), + sessTime: normalize711SessTimeForPanel( + sessTimeMatch ? sessTimeMatch[1] : (lifeMatch ? lifeMatch[1] : '') + ), + }; +} + +function apply711SessionConfigToUsernameForPanel(username = '', options = {}) { + const text = String(username || '').trim(); + if (!text) { + return ''; + } + + const sessionId = normalize711SessionIdForPanel(options?.sessionId || ''); + const sessTime = normalize711SessTimeForPanel(options?.sessTime || ''); + const removeWhenEmpty = Boolean(options?.removeWhenEmpty); + let next = text; + + if (sessionId) { + if (/(?:^|[-_])session[-_:][A-Za-z0-9_-]+?(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i.test(next)) { + next = next.replace( + /((?:^|[-_])session[-_:])([A-Za-z0-9_-]+?)(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i, + `$1${sessionId}` + ); + } else { + next = `${next}-session-${sessionId}`; + } + } else if (removeWhenEmpty) { + next = next.replace( + /(^|[-_])session[-_:][A-Za-z0-9_-]+?(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/ig, + '$1' + ); + } + + if (sessTime) { + if (/(?:^|[-_])sessTime[-_:]?\d+\b/i.test(next)) { + next = next.replace(/((?:^|[-_])sessTime[-_:]?)(\d+)\b/i, `$1${sessTime}`); + } else if (/(?:^|[-_])life[-_:]?\d+\b/i.test(next)) { + next = next.replace(/((?:^|[-_])life[-_:]?)(\d+)\b/i, `$1${sessTime}`); + } else { + next = `${next}-sessTime-${sessTime}`; + } + } else if (removeWhenEmpty) { + next = next.replace(/(^|[-_])sessTime[-_:]?\d+\b/ig, '$1'); + next = next.replace(/(^|[-_])life[-_:]?\d+\b/ig, '$1'); + } + + return String(next || '') + .replace(/[-_]{2,}/g, '-') + .replace(/^[-_]+|[-_]+$/g, '') + .trim(); +} + +function sync711SessionFieldsFromUsernameForPanel(options = {}) { + if (!shouldAutoSync711SessionFieldsForPanel(options?.state || latestState)) { + return { updated: false, sessionId: '', sessTime: '' }; + } + const username = String( + options?.username !== undefined + ? options.username + : (inputIpProxyUsername?.value || '') + ).trim(); + if (!username) { + return { updated: false, sessionId: '', sessTime: '' }; + } + + const parsed = parse711SessionConfigFromUsernameForPanel(username); + let updated = false; + + if (parsed.sessionId && inputIpProxyAccountSessionPrefix) { + const currentSession = normalizeIpProxyAccountSessionPrefix( + inputIpProxyAccountSessionPrefix.value || '' + ); + if (currentSession !== parsed.sessionId) { + inputIpProxyAccountSessionPrefix.value = parsed.sessionId; + updated = true; + } + } + + if (parsed.sessTime && inputIpProxyAccountLifeMinutes) { + const currentLife = normalizeIpProxyAccountLifeMinutes( + inputIpProxyAccountLifeMinutes.value || '' + ); + if (currentLife !== parsed.sessTime) { + inputIpProxyAccountLifeMinutes.value = parsed.sessTime; + updated = true; + } + } + + return { + updated, + sessionId: parsed.sessionId, + sessTime: parsed.sessTime, + }; +} + +function sync711RegionFieldFromUsernameForPanel(options = {}) { + if (!shouldAutoSync711SessionFieldsForPanel(options?.state || latestState)) { + return { updated: false, region: '' }; + } + if (!inputIpProxyRegion) { + return { updated: false, region: '' }; + } + + const username = String( + options?.username !== undefined + ? options.username + : (inputIpProxyUsername?.value || '') + ).trim(); + const parsedRegion = parse711RegionFromUsernameForPanel(username); + if (!parsedRegion) { + return { updated: false, region: '' }; + } + + const currentRegion = normalize711RegionCodeForPanel(inputIpProxyRegion.value || ''); + if (currentRegion === parsedRegion) { + return { updated: false, region: parsedRegion }; + } + inputIpProxyRegion.value = parsedRegion; + return { updated: true, region: parsedRegion }; +} + +function sync711UsernameFromSessionFieldsForPanel(options = {}) { + if (!shouldAutoSync711SessionFieldsForPanel(options?.state || latestState)) { + return { updated: false, username: '' }; + } + if (!inputIpProxyUsername) { + return { updated: false, username: '' }; + } + + const currentUsername = String(inputIpProxyUsername.value || '').trim(); + if (!currentUsername) { + return { updated: false, username: '' }; + } + + const sessionId = normalize711SessionIdForPanel( + options?.sessionId !== undefined + ? options.sessionId + : (inputIpProxyAccountSessionPrefix?.value || '') + ); + const lifeMinutes = normalizeIpProxyAccountLifeMinutes( + options?.sessTime !== undefined + ? options.sessTime + : (inputIpProxyAccountLifeMinutes?.value || '') + ); + const sessTime = normalize711SessTimeForPanel(lifeMinutes); + const nextUsername = apply711SessionConfigToUsernameForPanel(currentUsername, { + sessionId, + sessTime, + removeWhenEmpty: Boolean(options?.removeWhenEmpty), + }); + + if (!nextUsername || nextUsername === currentUsername) { + return { updated: false, username: currentUsername }; + } + + inputIpProxyUsername.value = nextUsername; + return { updated: true, username: nextUsername }; +} + +function sync711UsernameFromRegionForPanel(options = {}) { + if (!shouldAutoSync711SessionFieldsForPanel(options?.state || latestState)) { + return { updated: false, username: '' }; + } + if (!inputIpProxyUsername) { + return { updated: false, username: '' }; + } + + const currentUsername = String(inputIpProxyUsername.value || '').trim(); + if (!currentUsername) { + return { updated: false, username: '' }; + } + + const rawRegion = String( + options?.region !== undefined + ? options.region + : (inputIpProxyRegion?.value || '') + ).trim(); + const normalizedRegion = normalize711RegionCodeForPanel(rawRegion); + const nextUsername = apply711RegionToUsernameForPanel( + currentUsername, + normalizedRegion, + { removeWhenEmpty: Boolean(options?.removeWhenEmpty) } + ); + + if (!nextUsername || nextUsername === currentUsername) { + return { updated: false, username: currentUsername }; + } + + inputIpProxyUsername.value = nextUsername; + return { updated: true, username: nextUsername }; +} + +function canChangeIpProxyExitWithCurrentSession(state = latestState) { + const mode = getSelectedIpProxyMode(); + const service = normalizeIpProxyService( + selectIpProxyService?.value || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE + ); + if (mode !== 'account' || service !== '711proxy') { + return false; + } + const currentEntry = getIpProxyCurrentEntry(state); + const username = String(currentEntry?.username || inputIpProxyUsername?.value || '').trim(); + return has711SessionTokenForPanel(username); +} + +function buildIpProxyActionHintText(options = {}) { + const mode = normalizeIpProxyModeForCurrentRelease(options?.mode || DEFAULT_IP_PROXY_MODE); + const poolCount = Math.max(0, Number(options?.poolCount) || 0); + const changeAvailable = Boolean(options?.changeAvailable); + + if (mode === 'api') { + return '下一条:切到已拉取代理池的下一条。Change:仅账号模式可用。'; + } + + const nextPart = poolCount > 1 + ? '下一条:切到代理池的下一条节点。' + : '下一条:当前仅 1 条节点,执行重绑复测(不保证更换出口)。'; + const changePart = changeAvailable + ? 'Change:保持当前 session 重绑链路并复测出口。' + : 'Change:需 711 账号模式且用户名包含 session。'; + return `${nextPart} ${changePart}`; +} + +function setIpProxyCurrentDisplay(text = '', hasValue = false) { + if (!ipProxyCurrent) return; + ipProxyCurrent.textContent = text || '暂无可用代理'; + ipProxyCurrent.title = text || '暂无可用代理'; + ipProxyCurrent.classList.toggle('has-value', Boolean(hasValue)); +} + +function formatIpProxyCurrentDisplay(state = latestState) { + const mode = normalizeIpProxyModeForCurrentRelease(state?.ipProxyMode); + if (mode === 'account') { + const runtime = getIpProxyRuntimeSnapshot(state, mode); + const current = getIpProxyCurrentEntry(state); + if (!current) { + return { + text: '账号模式:请填写代理列表,或填写 Host / Port', + hasValue: false, + }; + } + const count = runtime.pool.length > 0 ? runtime.pool.length : 1; + const index = runtime.index; + return { + text: `${current.host}:${current.port}${current.region ? ` [${current.region}]` : ''} (${Math.min(index + 1, count)}/${count})`, + hasValue: true, + }; + } + + const runtime = getIpProxyRuntimeSnapshot(state, mode); + const current = getIpProxyCurrentEntry(state); + const count = runtime.pool.length; + const index = runtime.index; + if (!current) { + return { + text: count ? `已拉取 ${count} 条,点击“下一条”切换` : '暂无可用代理', + hasValue: false, + }; + } + const region = String(current.region || '').trim(); + const label = region ? `${current.host}:${current.port} [${region}]` : `${current.host}:${current.port}`; + return { + text: `${label}${count ? ` (${Math.min(index + 1, count)}/${count})` : ''}`, + hasValue: true, + }; +} + +function extractIpProxyEndpointToken(text = '') { + const raw = String(text || '').trim(); + if (!raw) return ''; + const match = raw.match(/^([^\s\[]+:\d+)/); + return match ? String(match[1]).toLowerCase() : ''; +} + +function extractIpProxyIndexToken(text = '') { + const raw = String(text || '').trim(); + if (!raw) return ''; + const match = raw.match(/\((\d+\s*\/\s*\d+)\)\s*$/); + if (!match) return ''; + return String(match[1]).replace(/\s+/g, ''); +} + +function buildIpProxyCurrentDisplayText(display = {}, runtimeStatus = {}) { + const rawText = String(display?.text || '').trim(); + const hasValue = Boolean(display?.hasValue); + if (!hasValue || !rawText) { + return rawText; + } + const runtimeText = String(runtimeStatus?.text || '').trim().toLowerCase(); + if (!runtimeText) { + return rawText; + } + const endpointToken = extractIpProxyEndpointToken(rawText); + if (!endpointToken || !runtimeText.includes(endpointToken)) { + return rawText; + } + const indexToken = extractIpProxyIndexToken(rawText); + if (indexToken) { + return `节点 ${indexToken}`; + } + return '当前节点'; +} + +function formatIpProxyRuntimeStatus(state = latestState) { + const enabled = Boolean(state?.ipProxyEnabled); + const mode = normalizeIpProxyModeForCurrentRelease(state?.ipProxyMode); + const hasAccountListConfigured = mode === 'account' + && normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean).length > 0; + const accountSourceTag = mode === 'account' + ? (hasAccountListConfigured ? '(账号列表)' : '(固定账号)') + : ''; + const accountSourceDetail = mode === 'account' + ? ( + hasAccountListConfigured + ? '当前生效来源:账号列表(固定账号字段已忽略)。' + : '当前生效来源:固定账号字段。' + ) + : ''; + const activeEntry = getIpProxyCurrentEntry(state); + const applied = Boolean(state?.ipProxyApplied); + const reason = String(state?.ipProxyAppliedReason || '').trim().toLowerCase(); + const host = String( + state?.ipProxyAppliedHost + || activeEntry?.host + || (mode === 'account' ? state?.ipProxyHost : '') + || '' + ).trim(); + const portValue = Number( + state?.ipProxyAppliedPort + || activeEntry?.port + || (mode === 'account' ? normalizeIpProxyPort(state?.ipProxyPort) : 0) + ); + const port = Number.isInteger(portValue) && portValue > 0 && portValue <= 65535 ? portValue : 0; + const region = String( + state?.ipProxyAppliedRegion + || activeEntry?.region + || (mode === 'account' ? state?.ipProxyRegion : '') + || '' + ).trim(); + const endpoint = host && port > 0 ? `${host}:${port}` : ''; + const endpointWithRegion = endpoint ? `${endpoint}${region ? ` [${region}]` : ''}` : ''; + const hasAuth = Boolean(state?.ipProxyAppliedHasAuth); + const authSuffix = hasAuth ? '(需要鉴权)' : ''; + const errorText = String(state?.ipProxyAppliedError || '').trim(); + const warningText = String(state?.ipProxyAppliedWarning || '').trim(); + const exitIp = String(state?.ipProxyAppliedExitIp || '').trim(); + const exitRegion = String(state?.ipProxyAppliedExitRegion || '').trim(); + const exitDetecting = Boolean(state?.ipProxyAppliedExitDetecting); + const exitError = String(state?.ipProxyAppliedExitError || '').trim(); + const exitSource = String(state?.ipProxyAppliedExitSource || '').trim().toLowerCase(); + const exitSourceSuffix = exitSource === 'page_context' + ? '(页面探测)' + : (exitSource === 'background_fallback' + ? '(后台兜底,可能受全局代理影响)' + : ''); + const endpointSummary = endpointWithRegion + ? `${endpointWithRegion}${authSuffix}` + : (endpoint ? `${endpoint}${authSuffix}` : ''); + const exitSummary = exitIp + ? `${exitIp}${exitRegion ? ` [${exitRegion}]` : ''}${exitSourceSuffix}` + : (exitDetecting ? '检测中...' : '未检测到'); + const details = [accountSourceDetail, errorText, warningText, exitError] + .map((item) => String(item || '').trim()) + .filter(Boolean) + .join('\n'); + + if (!enabled) { + return { + stateClass: 'state-idle', + text: '未启用,沿用浏览器默认/全局代理。', + details: '', + }; + } + + if (applied) { + const statusPrefix = endpointSummary + ? `当前代理:${endpointSummary}${accountSourceTag}` + : '当前代理:已启用'; + const statusText = `${statusPrefix};当前出口:${exitSummary}`; + const briefWarning = warningText + ? ';地区校验未通过(详情可展开查看)' + : ''; + return { + stateClass: warningText ? 'state-warning' : 'state-applied', + text: `${statusText}${briefWarning}`, + details, + }; + } + + if (reason === 'missing_proxy_entry') { + if (errorText) { + return { + stateClass: 'state-warning', + text: '已启用,但当前没有可用代理(已阻断直连)。', + details: errorText, + }; + } + return { + stateClass: 'state-warning', + text: mode === 'account' + ? '已启用,但账号模式没有可用代理。请先填写代理列表,或填写 Host/Port。已阻断所有网站直连。' + : '已启用,但当前没有可用代理。请先点击“拉取”获取 IP 列表。已阻断所有网站直连。', + details: '', + }; + } + if (reason === 'proxy_api_unavailable') { + return { + stateClass: 'state-error', + text: '已启用,但当前浏览器不支持扩展代理 API,无法应用。', + details, + }; + } + if (reason === 'apply_failed') { + return { + stateClass: 'state-error', + text: '已启用,但代理应用失败(已回退默认代理)。', + details: errorText || details, + }; + } + if (reason === 'connectivity_failed') { + const prefix = endpointSummary ? `当前代理:${endpointSummary}${accountSourceTag}` : '当前代理:未知'; + return { + stateClass: 'state-error', + text: `${prefix};连通性失败,请切换节点或重试。`, + details: errorText || details, + }; + } + + return { + stateClass: 'state-warning', + text: endpointWithRegion + ? `已启用,等待生效:${endpointWithRegion}${authSuffix}` + : '已启用,等待拉取并应用代理。', + details, + }; +} + +function setIpProxyRuntimeStatusDisplay(status = {}) { + if (!ipProxyRuntimeStatus || !ipProxyRuntimeText) { + return; + } + const text = String(status?.text || '未启用,沿用浏览器默认/全局代理。').trim(); + const stateClass = String(status?.stateClass || 'state-idle').trim() || 'state-idle'; + ipProxyRuntimeStatus.classList.remove('state-idle', 'state-applied', 'state-warning', 'state-error'); + ipProxyRuntimeStatus.classList.add(stateClass); + ipProxyRuntimeText.textContent = text; + ipProxyRuntimeText.title = text; + if (ipProxyRuntimeDot) { + ipProxyRuntimeDot.title = text; + } + const detailsText = String(status?.details || '').trim(); + if (ipProxyRuntimeDetails && ipProxyRuntimeDetailsText) { + if (detailsText) { + ipProxyRuntimeDetails.hidden = false; + ipProxyRuntimeDetailsText.textContent = detailsText; + } else { + ipProxyRuntimeDetails.hidden = true; + ipProxyRuntimeDetails.open = false; + ipProxyRuntimeDetailsText.textContent = ''; + } + } +} + +function normalizeIpProxyEnabledInlineRegion(value = '') { + const normalized = String(value || '').trim(); + return normalized ? normalized.toUpperCase() : ''; +} + +function setIpProxyEnabledInlineStatus(state = {}, enabled = getSelectedIpProxyEnabled()) { + if (!ipProxyEnabledStatus || !ipProxyEnabledStatusText) { + return; + } + ipProxyEnabledStatus.classList.remove('is-off', 'is-on'); + + if (!enabled) { + const text = '未开启'; + ipProxyEnabledStatus.classList.add('is-off'); + ipProxyEnabledStatusText.textContent = text; + ipProxyEnabledStatusText.title = text; + ipProxyEnabledStatus.title = text; + if (ipProxyEnabledStatusDot) { + ipProxyEnabledStatusDot.title = text; + } + return; + } + + const region = normalizeIpProxyEnabledInlineRegion(state?.ipProxyAppliedExitRegion) + || normalizeIpProxyEnabledInlineRegion(state?.ipProxyAppliedRegion) + || normalizeIpProxyEnabledInlineRegion(state?.ipProxyRegion); + const text = region ? `已开启 · ${region}` : '已开启'; + ipProxyEnabledStatus.classList.add('is-on'); + ipProxyEnabledStatusText.textContent = text; + ipProxyEnabledStatusText.title = text; + ipProxyEnabledStatus.title = text; + if (ipProxyEnabledStatusDot) { + ipProxyEnabledStatusDot.title = text; + } +} + +function updateIpProxyUI(state = latestState) { + const enabled = getSelectedIpProxyEnabled(); + const mode = getSelectedIpProxyMode(); + const service = normalizeIpProxyService(selectIpProxyService?.value || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE); + const apiModeAvailable = isIpProxyApiModeAvailable(); + const accountListAvailable = isIpProxyAccountListAvailable(); + const isApiMode = mode === 'api' && apiModeAvailable; + const isAccountMode = mode === 'account'; + const showSessionOptions = isAccountMode && service === '711proxy'; + const hasAccountListConfigured = accountListAvailable && isAccountMode && hasCurrentInputAccountListEntries(); + const canOperate = !isAutoRunLockedPhase() && !isAutoRunScheduledPhase(); + const actionState = getIpProxyActionState(); + const actionBusy = Boolean(actionState.busy); + const busyAction = normalizeIpProxyActionType(actionState.action); + const runtimeState = state || latestState || {}; + + setIpProxyEnabledInlineStatus(runtimeState, enabled); + + if (rowIpProxyEnabled) { + rowIpProxyEnabled.style.display = ''; + } + if (rowIpProxyFold) { + rowIpProxyFold.style.display = enabled ? '' : 'none'; + } + if (rowIpProxyService) { + rowIpProxyService.style.display = enabled ? '' : 'none'; + } + if (rowIpProxyMode) { + rowIpProxyMode.style.display = enabled ? '' : 'none'; + } + if (rowIpProxyLayout) { + rowIpProxyLayout.style.display = enabled ? '' : 'none'; + } + if (rowIpProxyApiUrl) { + rowIpProxyApiUrl.style.display = enabled && apiModeAvailable && isApiMode ? '' : 'none'; + } + if (rowIpProxyAccountList) { + rowIpProxyAccountList.style.display = enabled && isAccountMode && accountListAvailable ? '' : 'none'; + } + if (rowIpProxyAccountSessionPrefix) { + rowIpProxyAccountSessionPrefix.style.display = enabled && showSessionOptions ? '' : 'none'; + } + if (rowIpProxyAccountLifeMinutes) { + rowIpProxyAccountLifeMinutes.style.display = enabled && showSessionOptions ? '' : 'none'; + } + if (rowIpProxyPoolTargetCount) { + rowIpProxyPoolTargetCount.style.display = enabled ? '' : 'none'; + } + if (rowIpProxyHost) { + rowIpProxyHost.style.display = enabled && isAccountMode ? '' : 'none'; + } + if (rowIpProxyPort) { + rowIpProxyPort.style.display = enabled && isAccountMode ? '' : 'none'; + } + if (rowIpProxyProtocol) { + rowIpProxyProtocol.style.display = enabled ? '' : 'none'; + } + if (rowIpProxyUsername) { + rowIpProxyUsername.style.display = enabled && isAccountMode ? '' : 'none'; + } + if (rowIpProxyPassword) { + rowIpProxyPassword.style.display = enabled && isAccountMode ? '' : 'none'; + } + if (rowIpProxyRegion) { + rowIpProxyRegion.style.display = enabled && isAccountMode ? '' : 'none'; + } + if (rowIpProxyActions) { + rowIpProxyActions.style.display = enabled ? '' : 'none'; + } + if (ipProxyActionButtons) { + ipProxyActionButtons.style.display = enabled ? '' : 'none'; + } + if (rowIpProxyRuntimeStatus) { + rowIpProxyRuntimeStatus.style.display = enabled ? '' : 'none'; + } + if (ipProxyLayout) { + ipProxyLayout.classList.toggle('is-account-only', !apiModeAvailable); + } + if (selectIpProxyService) { + selectIpProxyService.value = service; + selectIpProxyService.disabled = true; + } + if (typeof updateIpProxyServiceLoginButtonState === 'function') { + updateIpProxyServiceLoginButtonState({ + service, + enabled, + }); + } + ipProxyModeButtons.forEach((button) => { + const buttonMode = normalizeIpProxyMode(button?.dataset?.ipProxyMode || DEFAULT_IP_PROXY_MODE); + const apiButton = buttonMode === 'api'; + button.disabled = apiButton && !apiModeAvailable; + if (apiButton) { + button.hidden = false; + } + }); + if (ipProxyApiPanel) { + ipProxyApiPanel.classList.toggle('is-disabled', !apiModeAvailable); + ipProxyApiPanel.setAttribute('aria-disabled', String(!apiModeAvailable)); + ipProxyApiPanel.hidden = !apiModeAvailable; + ipProxyApiPanel.style.display = enabled && apiModeAvailable ? '' : 'none'; + } + if (inputIpProxyApiUrl) { + inputIpProxyApiUrl.disabled = !enabled || !apiModeAvailable; + } + if (btnToggleIpProxyApiUrl) { + btnToggleIpProxyApiUrl.disabled = !enabled || !apiModeAvailable; + } + if (inputIpProxyHost) { + inputIpProxyHost.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (inputIpProxyPort) { + inputIpProxyPort.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (selectIpProxyProtocol) { + selectIpProxyProtocol.disabled = !enabled || (isAccountMode && hasAccountListConfigured); + } + if (inputIpProxyUsername) { + inputIpProxyUsername.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (inputIpProxyPassword) { + inputIpProxyPassword.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (btnToggleIpProxyUsername) { + btnToggleIpProxyUsername.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (btnToggleIpProxyPassword) { + btnToggleIpProxyPassword.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (inputIpProxyRegion) { + inputIpProxyRegion.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (inputIpProxyAccountSessionPrefix) { + inputIpProxyAccountSessionPrefix.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (inputIpProxyAccountLifeMinutes) { + inputIpProxyAccountLifeMinutes.disabled = !enabled || !isAccountMode || hasAccountListConfigured; + } + if (inputIpProxyAccountList) { + inputIpProxyAccountList.disabled = !enabled || !isAccountMode || !accountListAvailable; + } + + const runtimeStatus = formatIpProxyRuntimeStatus(runtimeState); + setIpProxyRuntimeStatusDisplay(runtimeStatus); + const currentDisplay = formatIpProxyCurrentDisplay(runtimeState); + const currentDisplayText = buildIpProxyCurrentDisplayText(currentDisplay, runtimeStatus); + setIpProxyCurrentDisplay(currentDisplayText, currentDisplay.hasValue); + const runtimeSnapshot = getIpProxyRuntimeSnapshot(runtimeState, mode, service); + const runtimePoolCount = Array.isArray(runtimeSnapshot?.pool) ? runtimeSnapshot.pool.length : 0; + const hasCurrentEntry = Boolean(getIpProxyCurrentEntry(runtimeState)); + const changeAvailable = canChangeIpProxyExitWithCurrentSession(runtimeState); + const nextActionTitle = runtimePoolCount > 1 + ? '切换到代理池下一条节点并应用' + : '当前仅 1 条节点:重绑当前节点并复测连通性(不保证更换出口)'; + + if (btnIpProxyRefresh) { + btnIpProxyRefresh.disabled = actionBusy || !enabled || !canOperate; + btnIpProxyRefresh.textContent = busyAction === 'refresh' + ? (isApiMode ? '拉取中...' : '同步中...') + : (isApiMode ? '拉取' : '同步'); + btnIpProxyRefresh.title = isApiMode ? '拉取代理池并应用当前代理' : '同步账号代理列表并应用当前代理'; + } + if (btnIpProxyNext) { + btnIpProxyNext.disabled = actionBusy || !enabled || !canOperate || !hasCurrentEntry; + btnIpProxyNext.textContent = busyAction === 'next' ? '切换中...' : '下一条'; + btnIpProxyNext.title = nextActionTitle; + } + if (btnIpProxyChange) { + btnIpProxyChange.disabled = actionBusy || !enabled || !canOperate || !changeAvailable; + btnIpProxyChange.textContent = busyAction === 'change' ? 'Change中...' : 'Change'; + btnIpProxyChange.title = changeAvailable + ? '保持当前会话并刷新出口(仅 711 + session)' + : '当前模式不支持 Change(需 711 账号模式且用户名包含 session)'; + } + if (btnIpProxyProbe) { + btnIpProxyProbe.disabled = actionBusy || !enabled || !canOperate; + btnIpProxyProbe.textContent = busyAction === 'probe' ? '检测中...' : '检测出口'; + } + if (btnIpProxyCheckIp) { + btnIpProxyCheckIp.disabled = false; + btnIpProxyCheckIp.title = '打开公网 IP 检测页'; + } + if (ipProxyActionHint) { + const actionHint = buildIpProxyActionHintText({ + mode, + poolCount: runtimePoolCount, + changeAvailable, + }); + ipProxyActionHint.textContent = actionHint; + ipProxyActionHint.title = actionHint; + } +} + +async function refreshIpProxyPoolByApi(options = {}) { + const { silent = false } = options; + const mode = normalizeIpProxyModeForCurrentRelease(getSelectedIpProxyMode()); + const response = await chrome.runtime.sendMessage({ + type: 'REFRESH_IP_PROXY_POOL', + source: 'sidepanel', + payload: { + mode, + }, + }); + if (response?.error) { + throw new Error(response.error); + } + + const responseMode = normalizeIpProxyModeForCurrentRelease(response?.mode || mode); + const patch = {}; + Object.assign(patch, buildIpProxyRuntimeStatePatchForMode(responseMode, response)); + if (response?.proxyRouting && typeof response.proxyRouting === 'object') { + patch.ipProxyApplied = Boolean(response.proxyRouting.applied); + patch.ipProxyAppliedReason = String(response.proxyRouting.reason || '').trim().toLowerCase(); + patch.ipProxyAppliedHost = String(response.proxyRouting.host || '').trim(); + patch.ipProxyAppliedPort = Number(response.proxyRouting.port) || 0; + patch.ipProxyAppliedRegion = String(response.proxyRouting.region || '').trim(); + patch.ipProxyAppliedHasAuth = Boolean(response.proxyRouting.hasAuth); + patch.ipProxyAppliedProvider = normalizeIpProxyService(response.proxyRouting.provider || ''); + patch.ipProxyAppliedError = String(response.proxyRouting.error || '').trim(); + patch.ipProxyAppliedWarning = String(response.proxyRouting.warning || '').trim(); + patch.ipProxyAppliedExitIp = String(response.proxyRouting.exitIp || '').trim(); + patch.ipProxyAppliedExitRegion = String(response.proxyRouting.exitRegion || '').trim(); + patch.ipProxyAppliedExitDetecting = Boolean(response.proxyRouting.exitDetecting); + patch.ipProxyAppliedExitError = String(response.proxyRouting.exitError || '').trim(); + patch.ipProxyAppliedExitSource = String(response.proxyRouting.exitSource || '').trim().toLowerCase(); + patch.ipProxyAppliedAt = Date.now(); + } + if (Object.keys(patch).length) { + syncLatestState(patch); + } + updateIpProxyUI(latestState); + await probeIpProxyExit({ silent: true }).catch(() => {}); + + if (!silent) { + if (mode === 'account') { + showToast(`已同步账号代理:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800); + } else { + showToast(`已拉取代理池:${Number(response?.count) || 0} 条`, 'success', 1800); + } + } + return response; +} + +async function switchIpProxyToNext(options = {}) { + const { silent = false } = options; + const mode = normalizeIpProxyModeForCurrentRelease(getSelectedIpProxyMode()); + const response = await chrome.runtime.sendMessage({ + type: 'SWITCH_IP_PROXY', + source: 'sidepanel', + payload: { + direction: 'next', + mode, + forceRefresh: false, + }, + }); + if (response?.error) { + throw new Error(response.error); + } + const responseMode = normalizeIpProxyModeForCurrentRelease(response?.mode || mode); + const patch = {}; + Object.assign(patch, buildIpProxyRuntimeStatePatchForMode(responseMode, response)); + if (response?.proxyRouting && typeof response.proxyRouting === 'object') { + patch.ipProxyApplied = Boolean(response.proxyRouting.applied); + patch.ipProxyAppliedReason = String(response.proxyRouting.reason || '').trim().toLowerCase(); + patch.ipProxyAppliedHost = String(response.proxyRouting.host || '').trim(); + patch.ipProxyAppliedPort = Number(response.proxyRouting.port) || 0; + patch.ipProxyAppliedRegion = String(response.proxyRouting.region || '').trim(); + patch.ipProxyAppliedHasAuth = Boolean(response.proxyRouting.hasAuth); + patch.ipProxyAppliedProvider = normalizeIpProxyService(response.proxyRouting.provider || ''); + patch.ipProxyAppliedError = String(response.proxyRouting.error || '').trim(); + patch.ipProxyAppliedWarning = String(response.proxyRouting.warning || '').trim(); + patch.ipProxyAppliedExitIp = String(response.proxyRouting.exitIp || '').trim(); + patch.ipProxyAppliedExitRegion = String(response.proxyRouting.exitRegion || '').trim(); + patch.ipProxyAppliedExitDetecting = Boolean(response.proxyRouting.exitDetecting); + patch.ipProxyAppliedExitError = String(response.proxyRouting.exitError || '').trim(); + patch.ipProxyAppliedExitSource = String(response.proxyRouting.exitSource || '').trim().toLowerCase(); + patch.ipProxyAppliedAt = Date.now(); + } + if (Object.keys(patch).length) { + syncLatestState(patch); + } + updateIpProxyUI(latestState); + await probeIpProxyExit({ silent: true }).catch(() => {}); + if (!silent) { + showToast(`已切换代理:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800); + } + return response; +} + +async function changeIpProxyExitBySession(options = {}) { + const { silent = false } = options; + const mode = normalizeIpProxyModeForCurrentRelease(getSelectedIpProxyMode()); + const response = await chrome.runtime.sendMessage({ + type: 'CHANGE_IP_PROXY_EXIT', + source: 'sidepanel', + payload: { + mode, + }, + }); + if (response?.error) { + throw new Error(response.error); + } + const responseMode = normalizeIpProxyModeForCurrentRelease(response?.mode || mode); + const patch = {}; + Object.assign(patch, buildIpProxyRuntimeStatePatchForMode(responseMode, response)); + if (response?.proxyRouting && typeof response.proxyRouting === 'object') { + patch.ipProxyApplied = Boolean(response.proxyRouting.applied); + patch.ipProxyAppliedReason = String(response.proxyRouting.reason || '').trim().toLowerCase(); + patch.ipProxyAppliedHost = String(response.proxyRouting.host || '').trim(); + patch.ipProxyAppliedPort = Number(response.proxyRouting.port) || 0; + patch.ipProxyAppliedRegion = String(response.proxyRouting.region || '').trim(); + patch.ipProxyAppliedHasAuth = Boolean(response.proxyRouting.hasAuth); + patch.ipProxyAppliedProvider = normalizeIpProxyService(response.proxyRouting.provider || ''); + patch.ipProxyAppliedError = String(response.proxyRouting.error || '').trim(); + patch.ipProxyAppliedWarning = String(response.proxyRouting.warning || '').trim(); + patch.ipProxyAppliedExitIp = String(response.proxyRouting.exitIp || '').trim(); + patch.ipProxyAppliedExitRegion = String(response.proxyRouting.exitRegion || '').trim(); + patch.ipProxyAppliedExitDetecting = Boolean(response.proxyRouting.exitDetecting); + patch.ipProxyAppliedExitError = String(response.proxyRouting.exitError || '').trim(); + patch.ipProxyAppliedExitSource = String(response.proxyRouting.exitSource || '').trim().toLowerCase(); + patch.ipProxyAppliedAt = Date.now(); + } + if (Object.keys(patch).length) { + syncLatestState(patch); + } + updateIpProxyUI(latestState); + await probeIpProxyExit({ silent: true }).catch(() => {}); + if (!silent) { + showToast(`已执行 Change:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800); + } + return response; +} + +async function probeIpProxyExit(options = {}) { + const { silent = false } = options; + const response = await chrome.runtime.sendMessage({ + type: 'PROBE_IP_PROXY_EXIT', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) { + throw new Error(response.error); + } + const routing = response?.proxyRouting || {}; + syncLatestState({ + ipProxyApplied: Boolean(routing.applied), + ipProxyAppliedReason: String(routing.reason || '').trim().toLowerCase(), + ipProxyAppliedHost: String(routing.host || '').trim(), + ipProxyAppliedPort: Number(routing.port) || 0, + ipProxyAppliedRegion: String(routing.region || '').trim(), + ipProxyAppliedHasAuth: Boolean(routing.hasAuth), + ipProxyAppliedProvider: normalizeIpProxyService(routing.provider || ''), + ipProxyAppliedError: String(routing.error || '').trim(), + ipProxyAppliedWarning: String(routing.warning || '').trim(), + ipProxyAppliedExitIp: String(routing.exitIp || '').trim(), + ipProxyAppliedExitRegion: String(routing.exitRegion || '').trim(), + ipProxyAppliedExitDetecting: Boolean(routing.exitDetecting), + ipProxyAppliedExitError: String(routing.exitError || '').trim(), + ipProxyAppliedExitSource: String(routing.exitSource || '').trim().toLowerCase(), + ipProxyAppliedAt: Date.now(), + }); + updateIpProxyUI(latestState); + + if (!silent) { + const exitIp = String(routing?.exitIp || '').trim(); + const exitRegion = String(routing?.exitRegion || '').trim(); + const exitSource = String(routing?.exitSource || '').trim().toLowerCase(); + const sourceHint = exitSource === 'background_fallback' + ? '(后台兜底,可能受全局代理影响)' + : ''; + if (exitIp) { + showToast(`出口检测成功:${exitIp}${exitRegion ? ` [${exitRegion}]` : ''}${sourceHint}`, 'success', 2600); + } else { + showToast('出口检测完成,但未获取到出口 IP', 'warn', 2200); + } + } + return response; +} diff --git a/sidepanel/ip-proxy-provider-711proxy.js b/sidepanel/ip-proxy-provider-711proxy.js new file mode 100644 index 0000000..63c6e16 --- /dev/null +++ b/sidepanel/ip-proxy-provider-711proxy.js @@ -0,0 +1,35 @@ +// sidepanel/ip-proxy-provider-711proxy.js — 711Proxy 面板专属逻辑 +function normalizeIpProxyCountryCode(value = '') { + const raw = String(value || '').trim().toUpperCase().replace(/[^A-Z]/g, ''); + return /^[A-Z]{2}$/.test(raw) ? raw : ''; +} + +function infer711RegionFromHost(host = '') { + const text = String(host || '').trim().toLowerCase().replace(/\.$/, ''); + if (!text || !text.includes('.')) { + return ''; + } + const firstLabel = String(text.split('.')[0] || '').trim(); + return /^[a-z]{2}$/.test(firstLabel) ? firstLabel.toUpperCase() : ''; +} + +function infer711RegionFromUsername(username = '') { + const text = String(username || '').trim(); + if (!text) { + return ''; + } + const match = text.match(/(?:^|[-_])region[-_:]?([A-Za-z]{2})\b/i); + return normalizeIpProxyCountryCode(match ? match[1] : ''); +} + +function resolve711ProxyRegionFromInputs({ host = '', username = '', region = '' } = {}) { + const fromUsername = infer711RegionFromUsername(username); + if (fromUsername) { + return fromUsername; + } + const fromHost = infer711RegionFromHost(host); + if (fromHost) { + return fromHost; + } + return normalizeIpProxyCountryCode(region); +} diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index d45da4a..df0dce6 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -781,6 +781,248 @@ header { min-width: 0; } +.ip-proxy-fold-row { + display: block; +} + +.ip-proxy-fold { + width: 100%; + border: none; + border-radius: 0; + background: transparent; + padding: 0; +} + +.ip-proxy-fold-body { + display: flex; + flex-direction: column; + gap: 8px; + border-top: none; + padding-top: 0; +} + +.ip-proxy-layout-row { + display: block; +} + +.ip-proxy-layout { + width: 100%; + display: block; +} + +.ip-proxy-layout.is-account-only { + display: block; +} + +.ip-proxy-column { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0; + border: none; + border-radius: 0; + background: transparent; +} + +.ip-proxy-column-header { + display: none; +} + +.ip-proxy-column-hint { + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); +} + +.ip-proxy-column-mode-btn { + width: 100%; + justify-content: center; +} + +.ip-proxy-column-api.is-disabled { + opacity: 0.55; +} + +.ip-proxy-column-api.is-disabled .data-input, +.ip-proxy-column-api.is-disabled .data-select, +.ip-proxy-column-api.is-disabled .data-textarea, +.ip-proxy-column-api.is-disabled .input-icon-btn, +.ip-proxy-column-api.is-disabled .btn, +.ip-proxy-column-api.is-disabled .choice-btn { + cursor: not-allowed; +} + +@media (max-width: 900px) { + .ip-proxy-layout { + display: block; + } +} + +.ip-proxy-enabled-inline { + justify-content: flex-end; + gap: 10px; +} + +.ip-proxy-actions-inline { + flex-wrap: wrap; + align-items: center; + row-gap: 6px; +} + +.ip-proxy-action-grid { + width: 100%; + display: flex; + flex-wrap: nowrap; + gap: 8px; +} + +.ip-proxy-action-grid .data-inline-btn { + flex: 1 1 0; + min-width: 0; +} + +.ip-proxy-action-hint { + flex: 1 1 100%; + font-size: 11px; + line-height: 1.5; + color: var(--text-secondary); +} + +.ip-proxy-enabled-status { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + min-width: 92px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-align: right; + white-space: nowrap; +} + +.ip-proxy-enabled-status-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--text-muted); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 25%, transparent); + flex-shrink: 0; +} + +.ip-proxy-enabled-status.is-on { + color: var(--green); +} + +.ip-proxy-enabled-status.is-on .ip-proxy-enabled-status-dot { + background: var(--green); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 30%, transparent); +} + +.ip-proxy-enabled-status.is-off { + color: var(--text-muted); +} + +.ip-proxy-runtime-status { + display: inline-flex; + align-items: flex-start; + gap: 8px; + width: 100%; + min-height: 32px; + padding: 6px 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} + +.ip-proxy-runtime-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.ip-proxy-runtime-main { + min-width: 0; +} + +.ip-proxy-runtime-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ip-proxy-check-ip-btn { + min-width: 64px; + padding-inline: 10px; + flex-shrink: 0; +} + +.ip-proxy-runtime-current { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--text-secondary); +} + +.ip-proxy-runtime-current.has-value { + color: var(--text-primary); +} + +.ip-proxy-runtime-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--text-muted); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 28%, transparent); + flex-shrink: 0; + margin-top: 6px; +} + +.ip-proxy-runtime-status.state-applied .ip-proxy-runtime-dot { + background: var(--green); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 28%, transparent); +} + +.ip-proxy-runtime-status.state-warning .ip-proxy-runtime-dot { + background: var(--orange); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 28%, transparent); +} + +.ip-proxy-runtime-status.state-error .ip-proxy-runtime-dot { + background: var(--red); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 28%, transparent); +} + +.ip-proxy-runtime-details { + margin: 0; + padding: 0; +} + +.ip-proxy-runtime-details summary { + cursor: pointer; + user-select: none; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; +} + +.ip-proxy-runtime-details[open] summary { + color: var(--text-primary); +} + +.ip-proxy-runtime-details-text { + margin-top: 4px; + font-size: 11px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} + .mail2925-base-inline { gap: 10px; } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 51afe3d..7db0a30 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -194,6 +194,163 @@ 默认代理 +
+ IP代理 +
+ + + + 未开启 + +
+
+