diff --git a/background.js b/background.js index b65b5d1..e6017a3 100644 --- a/background.js +++ b/background.js @@ -1,6 +1,8 @@ // background.js — Service Worker: orchestration, state, tab management, message routing importScripts( + 'shared/source-registry.js', + 'shared/flow-capabilities.js', 'managed-alias-utils.js', 'mail2925-utils.js', 'paypal-utils.js', @@ -17,8 +19,11 @@ importScripts( 'background/ip-proxy-core.js', 'background/panel-bridge.js', 'background/registration-email-state.js', + 'background/runtime-state.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', + 'background/mail-rule-registry.js', + 'flows/openai/mail-rules.js', 'background/message-router.js', 'background/verification-flow.js', 'background/auto-run-controller.js', @@ -80,6 +85,8 @@ const STEP_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite))) .sort((left, right) => left - right); +const DEFAULT_ACTIVE_FLOW_ID = 'openai'; +const DEFAULT_STEP_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite) @@ -205,6 +212,11 @@ const { isRecoverableStep9AuthFailure, } = self.MultiPageActivationUtils; const registrationEmailStateHelpers = self.MultiPageRegistrationEmailState?.createRegistrationEmailStateHelpers?.() || null; +const runtimeStateHelpers = self.MultiPageBackgroundRuntimeState?.createRuntimeStateHelpers?.({ + DEFAULT_ACTIVE_FLOW_ID, + defaultStepStatuses: DEFAULT_STEP_STATUSES, + getStepDefinitionForState: (step, state) => getStepDefinitionForState(step, state), +}) || null; const DEFAULT_REGISTRATION_EMAIL_STATE = registrationEmailStateHelpers?.DEFAULT_REGISTRATION_EMAIL_STATE || { current: '', previous: '', @@ -269,6 +281,20 @@ function getPreservedPhoneIdentity(state = {}) { return null; } +function buildStateViewWithRuntimeState(state = {}) { + if (runtimeStateHelpers?.buildStateView) { + return runtimeStateHelpers.buildStateView(state); + } + return state; +} + +function buildStatePatchWithRuntimeState(currentState = {}, updates = {}) { + if (runtimeStateHelpers?.buildSessionStatePatch) { + return runtimeStateHelpers.buildSessionStatePatch(currentState, updates); + } + return updates; +} + function statePatchHasChanges(state = {}, patch = {}) { return Object.keys(patch).some((key) => JSON.stringify(state?.[key] ?? null) !== JSON.stringify(patch[key] ?? null)); } @@ -844,8 +870,13 @@ const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings'; const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 20000; const DEFAULT_STATE = { + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + activeRunId: '', + currentNodeId: '', + nodeStatuses: {}, currentStep: 0, // 当前流程执行到的步骤编号。 - stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])), + stepStatuses: { ...DEFAULT_STEP_STATUSES }, + runtimeState: runtimeStateHelpers?.buildDefaultRuntimeState?.() || null, ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 resolvedSignupMethod: null, // 当前自动轮次冻结后的实际注册方式。 @@ -1320,7 +1351,49 @@ function normalizeSignupMethod(value = '') { : 'email'; } +function getFlowCapabilityRegistry() { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (typeof flowCapabilityRegistry !== 'undefined' && flowCapabilityRegistry) { + return flowCapabilityRegistry; + } + return rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; +} + +function resolveCurrentFlowCapabilities(state = {}, options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.resolveSidepanelCapabilities) { + return null; + } + return registry.resolveSidepanelCapabilities({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + function canUsePhoneSignup(state = {}) { + const capabilityState = typeof resolveCurrentFlowCapabilities === 'function' + ? resolveCurrentFlowCapabilities(state) + : (() => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId, + panelMode: state?.panelMode, + signupMethod: state?.signupMethod, + state, + }) + : null; + })(); + if (capabilityState && typeof capabilityState.canUsePhoneSignup === 'boolean') { + return capabilityState.canUsePhoneSignup; + } return Boolean(state?.phoneVerificationEnabled) && !Boolean(state?.plusModeEnabled) && !Boolean(state?.contributionMode); @@ -1332,9 +1405,26 @@ function resolveSignupMethod(state = {}) { return normalizeSignupMethod(frozenMethod); } const method = normalizeSignupMethod(state?.signupMethod); - return method === SIGNUP_METHOD_PHONE && canUsePhoneSignup(state) - ? SIGNUP_METHOD_PHONE - : SIGNUP_METHOD_EMAIL; + const capabilityState = typeof resolveCurrentFlowCapabilities === 'function' + ? resolveCurrentFlowCapabilities(state, { signupMethod: method }) + : (() => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId, + panelMode: state?.panelMode, + signupMethod: method, + state, + }) + : null; + })(); + if (capabilityState?.effectiveSignupMethod) { + return normalizeSignupMethod(capabilityState.effectiveSignupMethod); + } + return method === SIGNUP_METHOD_PHONE && canUsePhoneSignup(state) ? SIGNUP_METHOD_PHONE : SIGNUP_METHOD_EMAIL; } async function ensureResolvedSignupMethodForRun(options = {}) { @@ -2762,7 +2852,9 @@ function buildPersistentSettingsPayload(input = {}, options = {}) { }; if (Object.prototype.hasOwnProperty.call(payload, 'phoneVerificationEnabled') || Object.prototype.hasOwnProperty.call(payload, 'plusModeEnabled') - || Object.prototype.hasOwnProperty.call(payload, 'signupMethod')) { + || Object.prototype.hasOwnProperty.call(payload, 'signupMethod') + || Object.prototype.hasOwnProperty.call(payload, 'panelMode') + || Object.prototype.hasOwnProperty.call(payload, 'activeFlowId')) { payload.signupMethod = resolveSignupMethod(nextSignupConstraintState); } if (payload.ipProxyServiceProfiles) { @@ -2838,7 +2930,13 @@ async function getState() { getPersistedAliasState(), accountRunHistoryHelpers?.getPersistedAccountRunHistory?.() || [], ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state, accountRunHistory }; + return buildStateViewWithRuntimeState({ + ...DEFAULT_STATE, + ...persistedSettings, + ...persistedAliasState, + ...state, + accountRunHistory, + }); } async function initializeSessionStorageAccess() { @@ -2857,19 +2955,24 @@ async function initializeSessionStorageAccess() { async function setState(updates) { console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200)); if (Object.keys(updates || {}).length > 0) { - await chrome.storage.session.set(updates); + const currentSessionState = await chrome.storage.session.get(null); + const sessionUpdates = buildStatePatchWithRuntimeState({ + ...DEFAULT_STATE, + ...currentSessionState, + }, updates); + await chrome.storage.session.set(sessionUpdates); const persistentAliasUpdates = {}; - if (Object.prototype.hasOwnProperty.call(updates, 'manualAliasUsage')) { - persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(updates.manualAliasUsage); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'manualAliasUsage')) { + persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(sessionUpdates.manualAliasUsage); } - if (Object.prototype.hasOwnProperty.call(updates, 'preservedAliases')) { - persistentAliasUpdates.preservedAliases = normalizeBooleanMap(updates.preservedAliases); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'preservedAliases')) { + persistentAliasUpdates.preservedAliases = normalizeBooleanMap(sessionUpdates.preservedAliases); } - if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCache')) { - persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(updates.icloudAliasCache); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'icloudAliasCache')) { + persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(sessionUpdates.icloudAliasCache); } - if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCacheAt')) { - persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(updates.icloudAliasCacheAt) || 0); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'icloudAliasCacheAt')) { + persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(sessionUpdates.icloudAliasCacheAt) || 0); } if (Object.keys(persistentAliasUpdates).length > 0) { await chrome.storage.local.set(persistentAliasUpdates); @@ -3455,7 +3558,7 @@ async function resetState() { ? prev.freeReusablePhoneActivation : null; await chrome.storage.session.clear(); - await chrome.storage.session.set({ + const resetPayload = buildStatePatchWithRuntimeState({}, { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, @@ -3485,6 +3588,7 @@ async function resetState() { ? Number(prev.automationWindowId) : null, }); + await chrome.storage.session.set(resetPayload); } /** @@ -7210,7 +7314,7 @@ function isSignupEntryHost(hostname = '') { if (typeof navigationUtils !== 'undefined' && navigationUtils?.isSignupEntryHost) { return navigationUtils.isSignupEntryHost(hostname); } - return ['chatgpt.com', 'chat.openai.com'].includes(hostname); + return ['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(hostname); } function isLikelyLoggedInChatgptHomeUrl(rawUrl) { @@ -7294,6 +7398,7 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { if (!candidate) return false; const reference = parseUrlSafely(referenceUrl); switch (source) { + case 'openai-auth': case 'signup-page': return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); case 'duck-mail': @@ -7304,6 +7409,8 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { return is163MailHost(candidate.hostname); case 'gmail-mail': return candidate.hostname === 'mail.google.com'; + case 'icloud-mail': + return candidate.hostname === 'www.icloud.com' || candidate.hostname === 'www.icloud.com.cn'; case 'inbucket-mail': return Boolean(reference) && candidate.origin === reference.origin && candidate.pathname.startsWith('/m/'); case 'mail-2925': @@ -7314,11 +7421,24 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { return Boolean(reference) && candidate.origin === reference.origin && (candidate.pathname.startsWith('/admin/accounts') || candidate.pathname.startsWith('/login') || candidate.pathname === '/'); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && (candidate.pathname.startsWith('/admin/accounts') || candidate.pathname === '/admin' || candidate.pathname === '/'); default: return false; } } +function sourcesMatch(leftSource, rightSource) { + if (sourceRegistry?.resolveCanonicalSource) { + const left = sourceRegistry.resolveCanonicalSource(leftSource); + const right = sourceRegistry.resolveCanonicalSource(rightSource); + return Boolean(left && right && left === right); + } + return String(leftSource || '').trim() === String(rightSource || '').trim(); +} + async function rememberSourceLastUrl(source, url) { return tabRuntime.rememberSourceLastUrl(source, url); } @@ -7399,7 +7519,7 @@ async function ensureContentScriptReadyOnTab(source, tabId, options = {}) { function isContentScriptReadyPong(source, pong) { if (!pong?.ok) return false; - if (pong.source && pong.source !== source) return false; + if (pong.source && !sourcesMatch(pong.source, source)) return false; if (source === 'plus-checkout') { return Boolean(pong.plusCheckoutReady); } @@ -7590,11 +7710,13 @@ function getSourceLabel(source) { return loggingStatus.getSourceLabel(source); } const labels = { + 'openai-auth': '认证页', 'gmail-mail': 'Gmail 邮箱', 'sidepanel': '侧边栏', 'signup-page': '认证页', 'vps-panel': 'CPA 面板', 'sub2api-panel': 'SUB2API 后台', + 'codex2api-panel': 'Codex2API 后台', 'qq-mail': 'QQ 邮箱', 'mail-163': '163 邮箱', 'mail-2925': '2925 邮箱', @@ -7606,6 +7728,8 @@ function getSourceLabel(source) { 'cloudmail': 'Cloud Mail', 'plus-checkout': 'Plus Checkout', 'paypal-flow': 'PayPal 授权页', + 'gopay-flow': 'GoPay 授权页', + 'unknown-source': '未知来源', }; return labels[source] || source || '未知来源'; } @@ -7659,10 +7783,16 @@ function getStepFetchNetworkRetryPolicy(step) { }; } +const sourceRegistry = self.MultiPageSourceRegistry?.createSourceRegistry?.() || null; +const flowCapabilityRegistry = self.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, +}) || null; + const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({ DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, + sourceRegistry, }); const loggingStatus = self.MultiPageBackgroundLoggingStatus?.createLoggingStatus({ @@ -7672,6 +7802,7 @@ const loggingStatus = self.MultiPageBackgroundLoggingStatus?.createLoggingStatus isRecoverableStep9AuthFailure, LOG_PREFIX, setState, + sourceRegistry, STOP_ERROR_MESSAGE, }); @@ -7684,6 +7815,7 @@ const tabRuntime = self.MultiPageBackgroundTabRuntime?.createTabRuntime({ isRetryableContentScriptTransportError, LOG_PREFIX, matchesSourceUrlFamily, + sourceRegistry, setState, sleepWithStop, STOP_ERROR_MESSAGE, @@ -11251,8 +11383,20 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe waitForTabStableComplete, waitForTabUrlMatch, }); +const openAiMailRules = self.MultiPageOpenAiMailRules?.createOpenAiMailRules({ + getHotmailVerificationRequestTimestamp, + MAIL_2925_VERIFICATION_INTERVAL_MS, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS, +}); +const mailRuleRegistry = self.MultiPageBackgroundMailRuleRegistry?.createMailRuleRegistry({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, + flowBuilders: { + openai: openAiMailRules, + }, +}); const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.createVerificationFlowHelpers({ addLog, + buildVerificationPollPayload: mailRuleRegistry?.buildVerificationPollPayload, chrome, closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, diff --git a/background/logging-status.js b/background/logging-status.js index b86afa8..f92cd39 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -9,16 +9,22 @@ isRecoverableStep9AuthFailure, LOG_PREFIX, setState, + sourceRegistry = null, STOP_ERROR_MESSAGE, } = deps; function getSourceLabel(source) { + if (sourceRegistry?.getSourceLabel) { + return sourceRegistry.getSourceLabel(source); + } const labels = { + 'openai-auth': '认证页', 'gmail-mail': 'Gmail 邮箱', 'sidepanel': '侧边栏', 'signup-page': '认证页', 'vps-panel': 'CPA 面板', 'sub2api-panel': 'SUB2API 后台', + 'codex2api-panel': 'Codex2API 后台', 'qq-mail': 'QQ 邮箱', 'mail-163': '163 邮箱', 'mail-2925': '2925 邮箱', @@ -28,6 +34,10 @@ 'luckmail-api': 'LuckMail(API 购邮)', 'cloudflare-temp-email': 'Cloudflare Temp Email', 'cloudmail': 'Cloud Mail', + 'plus-checkout': 'Plus Checkout', + 'paypal-flow': 'PayPal 授权页', + 'gopay-flow': 'GoPay 授权页', + 'unknown-source': '未知来源', }; return labels[source] || source || '未知来源'; } diff --git a/background/mail-rule-registry.js b/background/mail-rule-registry.js new file mode 100644 index 0000000..be9848f --- /dev/null +++ b/background/mail-rule-registry.js @@ -0,0 +1,48 @@ +(function attachBackgroundMailRuleRegistry(root, factory) { + root.MultiPageBackgroundMailRuleRegistry = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundMailRuleRegistryModule() { + function createMailRuleRegistry(deps = {}) { + const { + defaultFlowId = 'openai', + flowBuilders = {}, + } = deps; + + function resolveFlowId(state = {}) { + const activeFlowId = String(state?.activeFlowId || '').trim(); + return activeFlowId || String(defaultFlowId || '').trim() || 'openai'; + } + + function getFlowBuilder(flowId) { + const normalizedFlowId = String(flowId || '').trim(); + return normalizedFlowId ? flowBuilders[normalizedFlowId] || null : null; + } + + function getVerificationMailRule(step, state = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder || typeof flowBuilder.getRuleDefinition !== 'function') { + throw new Error(`未找到 flow=${flowId} 的邮件规则定义。`); + } + return flowBuilder.getRuleDefinition(step, state); + } + + function buildVerificationPollPayload(step, state = {}, overrides = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder || typeof flowBuilder.buildVerificationPollPayload !== 'function') { + throw new Error(`未找到 flow=${flowId} 的邮件轮询规则构造器。`); + } + return flowBuilder.buildVerificationPollPayload(step, state, overrides); + } + + return { + buildVerificationPollPayload, + getVerificationMailRule, + resolveFlowId, + }; + } + + return { + createMailRuleRegistry, + }; +}); diff --git a/background/message-router.js b/background/message-router.js index f3127a0..25ed7b4 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -51,11 +51,27 @@ getStepIdsForState, getLastStepIdForState, normalizeSignupMethod = (value = '') => String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email', - canUsePhoneSignup = (state = {}) => Boolean(state?.phoneVerificationEnabled) - && !Boolean(state?.plusModeEnabled) - && !Boolean(state?.contributionMode), + canUsePhoneSignup = (state = {}) => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (capabilityRegistry?.canUsePhoneSignup) { + return capabilityRegistry.canUsePhoneSignup(state); + } + return Boolean(state?.phoneVerificationEnabled) + && !Boolean(state?.plusModeEnabled) + && !Boolean(state?.contributionMode); + }, resolveSignupMethod = (state = {}) => { const method = normalizeSignupMethod(state?.signupMethod); + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (capabilityRegistry?.resolveSignupMethod) { + return capabilityRegistry.resolveSignupMethod(state, method); + } return method === 'phone' && canUsePhoneSignup(state) ? 'phone' : 'email'; }, getTabId, @@ -999,6 +1015,8 @@ Object.prototype.hasOwnProperty.call(updates, 'phoneVerificationEnabled') || Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled') || Object.prototype.hasOwnProperty.call(updates, 'signupMethod') + || Object.prototype.hasOwnProperty.call(updates, 'panelMode') + || Object.prototype.hasOwnProperty.call(updates, 'activeFlowId') ) { updates.signupMethod = resolveSignupMethod(nextSignupState); } diff --git a/background/navigation-utils.js b/background/navigation-utils.js index 9077502..2353973 100644 --- a/background/navigation-utils.js +++ b/background/navigation-utils.js @@ -6,6 +6,7 @@ DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, + sourceRegistry = null, } = deps; function parseUrlSafely(rawUrl) { @@ -66,7 +67,7 @@ } function isSignupEntryHost(hostname = '') { - return ['chatgpt.com', 'chat.openai.com'].includes(hostname); + return ['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(hostname); } function isSignupPasswordPageUrl(rawUrl) { @@ -117,12 +118,16 @@ } function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { + if (sourceRegistry?.matchesSourceUrlFamily) { + return sourceRegistry.matchesSourceUrlFamily(source, candidateUrl, referenceUrl); + } const candidate = parseUrlSafely(candidateUrl); if (!candidate) return false; const reference = parseUrlSafely(referenceUrl); switch (source) { + case 'openai-auth': case 'signup-page': return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); case 'duck-mail': diff --git a/background/runtime-state.js b/background/runtime-state.js new file mode 100644 index 0000000..cfacddd --- /dev/null +++ b/background/runtime-state.js @@ -0,0 +1,484 @@ +(function attachBackgroundRuntimeState(root, factory) { + root.MultiPageBackgroundRuntimeState = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundRuntimeStateModule() { + function createRuntimeStateHelpers(deps = {}) { + const { + DEFAULT_ACTIVE_FLOW_ID = 'openai', + defaultStepStatuses = {}, + getStepDefinitionForState = null, + } = deps; + + const RUNTIME_SHARED_FIELDS = Object.freeze([ + 'automationWindowId', + 'tabRegistry', + 'sourceLastUrls', + 'flowStartTime', + ]); + const RUNTIME_PROXY_FIELDS = Object.freeze([ + 'ipProxyApiPool', + 'ipProxyApiCurrentIndex', + 'ipProxyApiCurrent', + 'ipProxyAccountPool', + 'ipProxyAccountCurrentIndex', + 'ipProxyAccountCurrent', + 'ipProxyPool', + 'ipProxyCurrentIndex', + 'ipProxyCurrent', + ]); + const OPENAI_FLOW_FIELD_GROUPS = Object.freeze({ + auth: Object.freeze([ + 'oauthUrl', + 'localhostUrl', + ]), + platformBinding: Object.freeze([ + 'cpaOAuthState', + 'cpaManagementOrigin', + 'sub2apiSessionId', + 'sub2apiOAuthState', + 'sub2apiGroupId', + 'sub2apiDraftName', + 'sub2apiProxyId', + 'sub2apiGroupIds', + 'codex2apiSessionId', + 'codex2apiOAuthState', + ]), + plus: Object.freeze([ + 'plusCheckoutTabId', + 'plusCheckoutUrl', + 'plusCheckoutCountry', + 'plusCheckoutCurrency', + 'plusCheckoutSource', + 'plusBillingCountryText', + 'plusBillingAddress', + 'plusPaypalApprovedAt', + 'plusGoPayApprovedAt', + 'plusReturnUrl', + 'plusManualConfirmationPending', + 'plusManualConfirmationRequestId', + 'plusManualConfirmationStep', + 'plusManualConfirmationMethod', + 'plusManualConfirmationTitle', + 'plusManualConfirmationMessage', + 'gopayHelperReferenceId', + 'gopayHelperGoPayGuid', + 'gopayHelperRedirectUrl', + 'gopayHelperNextAction', + 'gopayHelperFlowId', + 'gopayHelperChallengeId', + 'gopayHelperStartPayload', + 'gopayHelperTaskId', + 'gopayHelperTaskStatus', + 'gopayHelperStatusText', + 'gopayHelperRemoteStage', + 'gopayHelperApiWaitingFor', + 'gopayHelperApiInputDeadlineAt', + 'gopayHelperApiInputWaitSeconds', + 'gopayHelperLastInputError', + 'gopayHelperOtpInvalidCount', + 'gopayHelperFailureStage', + 'gopayHelperFailureDetail', + 'gopayHelperTaskPayload', + 'gopayHelperOrderCreatedAt', + 'gopayHelperTaskProgressSignature', + 'gopayHelperTaskProgressAt', + 'gopayHelperTaskProgressTaskId', + 'gopayHelperPinPayload', + 'gopayHelperResolvedOtp', + 'gopayHelperOtpRequestId', + 'gopayHelperOtpReferenceId', + ]), + phoneVerification: Object.freeze([ + 'currentPhoneActivation', + 'phoneNumber', + 'currentPhoneVerificationCode', + 'currentPhoneVerificationCountdownEndsAt', + 'currentPhoneVerificationCountdownWindowIndex', + 'currentPhoneVerificationCountdownWindowTotal', + 'reusablePhoneActivation', + 'freeReusablePhoneActivation', + 'phoneReusableActivationPool', + 'signupPhoneNumber', + 'signupPhoneActivation', + 'signupPhoneCompletedActivation', + 'signupPhoneVerificationRequestedAt', + 'signupPhoneVerificationPurpose', + ]), + luckmail: Object.freeze([ + 'currentLuckmailPurchase', + 'currentLuckmailMailCursor', + ]), + identity: Object.freeze([ + 'resolvedSignupMethod', + 'accountIdentifierType', + 'accountIdentifier', + 'registrationEmailState', + 'email', + 'password', + 'lastEmailTimestamp', + 'lastSignupCode', + 'lastLoginCode', + 'step8VerificationTargetEmail', + ]), + }); + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cloneValue(value) { + if (Array.isArray(value)) { + return value.map((item) => cloneValue(item)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, cloneValue(entryValue)]) + ); + } + return value; + } + + function normalizePlainObject(value) { + return isPlainObject(value) ? value : {}; + } + + function normalizeFlowId(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized || DEFAULT_ACTIVE_FLOW_ID; + } + + function normalizeRunId(value = '') { + return String(value || '').trim(); + } + + function normalizeStepNumber(value) { + const step = Math.floor(Number(value) || 0); + return step > 0 ? step : 0; + } + + function normalizeNodeStatus(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return 'pending'; + } + return normalized; + } + + function buildDefaultStepStatuses() { + return Object.fromEntries( + Object.entries(normalizePlainObject(defaultStepStatuses)).map(([key, value]) => [ + String(key), + normalizeNodeStatus(value), + ]) + ); + } + + function normalizeStepStatuses(value) { + const base = buildDefaultStepStatuses(); + if (!isPlainObject(value)) { + return base; + } + + const next = { ...base }; + for (const [key, status] of Object.entries(value)) { + const step = normalizeStepNumber(key); + if (!step) continue; + next[String(step)] = normalizeNodeStatus(status); + } + return next; + } + + function normalizeLegacyStepCompat(value = {}, state = {}) { + const candidate = normalizePlainObject(value); + const currentStep = normalizeStepNumber( + Object.prototype.hasOwnProperty.call(state, 'currentStep') + ? state.currentStep + : candidate.currentStep + ); + const stepStatuses = normalizeStepStatuses( + Object.prototype.hasOwnProperty.call(state, 'stepStatuses') + ? state.stepStatuses + : candidate.stepStatuses + ); + + return { + currentStep, + stepStatuses, + }; + } + + function resolveStepKey(step, state = {}) { + const numericStep = normalizeStepNumber(step); + if (!numericStep || typeof getStepDefinitionForState !== 'function') { + return ''; + } + return String(getStepDefinitionForState(numericStep, state)?.key || '').trim(); + } + + function normalizeNodeStatuses(value, state = {}, legacyStepCompat = null) { + if (isPlainObject(value) && Object.keys(value).length > 0) { + return Object.fromEntries( + Object.entries(value) + .map(([key, status]) => [String(key || '').trim(), normalizeNodeStatus(status)]) + .filter(([key]) => Boolean(key)) + ); + } + + const compat = legacyStepCompat || normalizeLegacyStepCompat({}, state); + const next = {}; + for (const [stepKey, status] of Object.entries(compat.stepStatuses || {})) { + const step = normalizeStepNumber(stepKey); + if (!step) continue; + const nodeKey = resolveStepKey(step, state) || `step-${step}`; + next[nodeKey] = normalizeNodeStatus(status); + } + return next; + } + + function pickDefinedFields(state = {}, fields = []) { + const next = {}; + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(state, field)) { + next[field] = cloneValue(state[field]); + } + } + return next; + } + + function buildSharedState(baseValue = {}, state = {}) { + return { + ...cloneValue(normalizePlainObject(baseValue)), + ...pickDefinedFields(state, RUNTIME_SHARED_FIELDS), + }; + } + + function buildServiceState(baseValue = {}, state = {}) { + const base = cloneValue(normalizePlainObject(baseValue)); + return { + ...base, + proxy: { + ...cloneValue(normalizePlainObject(base.proxy)), + ...pickDefinedFields(state, RUNTIME_PROXY_FIELDS), + }, + }; + } + + function flattenOpenAiFlowState(flowState = {}) { + const openaiState = normalizePlainObject(flowState.openai); + const next = {}; + for (const [groupKey, fields] of Object.entries(OPENAI_FLOW_FIELD_GROUPS)) { + const group = normalizePlainObject(openaiState[groupKey]); + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(group, field)) { + next[field] = cloneValue(group[field]); + } + } + } + return next; + } + + function buildOpenAiFlowState(baseValue = {}, state = {}) { + const baseFlowState = cloneValue(normalizePlainObject(baseValue)); + const baseOpenAi = cloneValue(normalizePlainObject(baseFlowState.openai)); + const openaiState = { + ...baseOpenAi, + }; + + for (const [groupKey, fields] of Object.entries(OPENAI_FLOW_FIELD_GROUPS)) { + openaiState[groupKey] = { + ...cloneValue(normalizePlainObject(baseOpenAi[groupKey])), + ...pickDefinedFields(state, fields), + }; + } + + return { + ...baseFlowState, + openai: openaiState, + }; + } + + function buildRuntimeStateDefault() { + return { + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + activeRunId: '', + currentNodeId: '', + nodeStatuses: {}, + sharedState: {}, + serviceState: { + proxy: {}, + }, + flowState: { + openai: { + auth: {}, + platformBinding: {}, + plus: {}, + phoneVerification: {}, + luckmail: {}, + identity: {}, + }, + }, + legacyStepCompat: { + currentStep: 0, + stepStatuses: buildDefaultStepStatuses(), + }, + }; + } + + function ensureRuntimeState(state = {}) { + const baseRuntimeState = { + ...buildRuntimeStateDefault(), + ...cloneValue(normalizePlainObject(state.runtimeState)), + }; + const activeFlowId = normalizeFlowId( + Object.prototype.hasOwnProperty.call(state, 'activeFlowId') + ? state.activeFlowId + : baseRuntimeState.activeFlowId + ); + const legacyStepCompat = normalizeLegacyStepCompat(baseRuntimeState.legacyStepCompat, state); + const currentNodeId = String( + Object.prototype.hasOwnProperty.call(state, 'currentNodeId') + ? state.currentNodeId + : (baseRuntimeState.currentNodeId || resolveStepKey(legacyStepCompat.currentStep, state)) + ).trim(); + const nodeStatuses = normalizeNodeStatuses( + Object.prototype.hasOwnProperty.call(state, 'nodeStatuses') + ? state.nodeStatuses + : baseRuntimeState.nodeStatuses, + state, + legacyStepCompat + ); + + return { + ...baseRuntimeState, + activeFlowId, + activeRunId: normalizeRunId( + Object.prototype.hasOwnProperty.call(state, 'activeRunId') + ? state.activeRunId + : baseRuntimeState.activeRunId + ), + currentNodeId, + nodeStatuses, + sharedState: buildSharedState(baseRuntimeState.sharedState, state), + serviceState: buildServiceState(baseRuntimeState.serviceState, state), + flowState: buildOpenAiFlowState(baseRuntimeState.flowState, state), + legacyStepCompat, + }; + } + + function buildFlattenedUpdates(updates = {}) { + const next = { + ...updates, + }; + const runtimeState = normalizePlainObject(updates.runtimeState); + const legacyStepCompat = normalizePlainObject(updates.legacyStepCompat); + const sharedState = normalizePlainObject(updates.sharedState); + const serviceState = normalizePlainObject(updates.serviceState); + const flowState = normalizePlainObject(updates.flowState); + + if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeFlowId')) { + next.activeFlowId = runtimeState.activeFlowId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeRunId')) { + next.activeRunId = runtimeState.activeRunId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'currentNodeId')) { + next.currentNodeId = runtimeState.currentNodeId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'nodeStatuses')) { + next.nodeStatuses = cloneValue(runtimeState.nodeStatuses); + } + if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'currentStep')) { + next.currentStep = legacyStepCompat.currentStep; + } + if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'stepStatuses')) { + next.stepStatuses = cloneValue(legacyStepCompat.stepStatuses); + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'legacyStepCompat')) { + const compatCandidate = normalizePlainObject(runtimeState.legacyStepCompat); + if (Object.prototype.hasOwnProperty.call(compatCandidate, 'currentStep')) { + next.currentStep = compatCandidate.currentStep; + } + if (Object.prototype.hasOwnProperty.call(compatCandidate, 'stepStatuses')) { + next.stepStatuses = cloneValue(compatCandidate.stepStatuses); + } + } + + Object.assign(next, pickDefinedFields(sharedState, RUNTIME_SHARED_FIELDS)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'sharedState')) { + Object.assign( + next, + pickDefinedFields(normalizePlainObject(runtimeState.sharedState), RUNTIME_SHARED_FIELDS) + ); + } + + const serviceProxy = normalizePlainObject(serviceState.proxy); + Object.assign(next, pickDefinedFields(serviceProxy, RUNTIME_PROXY_FIELDS)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'serviceState')) { + const runtimeServiceState = normalizePlainObject(runtimeState.serviceState); + Object.assign( + next, + pickDefinedFields(normalizePlainObject(runtimeServiceState.proxy), RUNTIME_PROXY_FIELDS) + ); + } + + Object.assign(next, flattenOpenAiFlowState(flowState)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'flowState')) { + Object.assign(next, flattenOpenAiFlowState(normalizePlainObject(runtimeState.flowState))); + } + + return next; + } + + function buildStateView(state = {}) { + const runtimeState = ensureRuntimeState(state); + return { + ...state, + activeFlowId: runtimeState.activeFlowId, + activeRunId: runtimeState.activeRunId, + currentNodeId: runtimeState.currentNodeId, + nodeStatuses: cloneValue(runtimeState.nodeStatuses), + flowState: cloneValue(runtimeState.flowState), + sharedState: cloneValue(runtimeState.sharedState), + serviceState: cloneValue(runtimeState.serviceState), + legacyStepCompat: cloneValue(runtimeState.legacyStepCompat), + currentStep: runtimeState.legacyStepCompat.currentStep, + stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), + runtimeState, + }; + } + + function buildSessionStatePatch(currentState = {}, updates = {}) { + const flattenedUpdates = buildFlattenedUpdates(updates); + const nextState = { + ...currentState, + ...flattenedUpdates, + }; + const runtimeState = ensureRuntimeState(nextState); + + return { + ...flattenedUpdates, + activeFlowId: runtimeState.activeFlowId, + activeRunId: runtimeState.activeRunId, + currentNodeId: runtimeState.currentNodeId, + nodeStatuses: cloneValue(runtimeState.nodeStatuses), + currentStep: runtimeState.legacyStepCompat.currentStep, + stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), + runtimeState, + }; + } + + return { + DEFAULT_ACTIVE_FLOW_ID, + OPENAI_FLOW_FIELD_GROUPS, + RUNTIME_PROXY_FIELDS, + RUNTIME_SHARED_FIELDS, + buildDefaultRuntimeState: buildRuntimeStateDefault, + buildSessionStatePatch, + buildStateView, + ensureRuntimeState, + }; + } + + return { + createRuntimeStateHelpers, + }; +}); diff --git a/background/tab-runtime.js b/background/tab-runtime.js index f08f1a3..b689715 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -11,6 +11,7 @@ isRetryableContentScriptTransportError, LOG_PREFIX, matchesSourceUrlFamily, + sourceRegistry = null, setState, sleepWithStop, STOP_ERROR_MESSAGE, @@ -19,6 +20,65 @@ const pendingCommands = new Map(); + function resolveCanonicalSource(source) { + if (sourceRegistry?.resolveCanonicalSource) { + return sourceRegistry.resolveCanonicalSource(source); + } + return String(source || '').trim(); + } + + function getSourceKeys(source) { + if (sourceRegistry?.getSourceKeys) { + const registryKeys = sourceRegistry.getSourceKeys(source); + if (Array.isArray(registryKeys) && registryKeys.length) { + return registryKeys; + } + } + const normalized = String(source || '').trim(); + return normalized ? [normalized] : []; + } + + function getSourceCommandKey(source) { + const keys = getSourceKeys(source); + return keys[0] || String(source || '').trim(); + } + + function sourcesMatch(leftSource, rightSource) { + const left = resolveCanonicalSource(leftSource); + const right = resolveCanonicalSource(rightSource); + return Boolean(left && right && left === right); + } + + function getSourceMapValue(record, source) { + const map = record && typeof record === 'object' ? record : {}; + for (const key of getSourceKeys(source)) { + if (Object.prototype.hasOwnProperty.call(map, key)) { + return map[key]; + } + } + return undefined; + } + + function setSourceMapValue(record, source, value) { + const nextRecord = { ...(record || {}) }; + const keys = getSourceKeys(source); + const canonicalKey = keys[0] || String(source || '').trim(); + for (const key of keys.slice(1)) { + delete nextRecord[key]; + } + if (canonicalKey) { + nextRecord[canonicalKey] = value; + } + return nextRecord; + } + + function getCleanupOwnerSource(cleanupScope) { + if (sourceRegistry?.getCleanupOwnerSource) { + return sourceRegistry.getCleanupOwnerSource(cleanupScope); + } + return cleanupScope === 'oauth-localhost-callback' ? 'signup-page' : ''; + } + function normalizeAutomationWindowId(value) { if (value === null || value === undefined || value === '') { return null; @@ -170,7 +230,7 @@ } async function registerTab(source, tabId) { - const registry = await getTabRegistry(); + let registry = await getTabRegistry(); let windowId = null; if (chrome?.tabs?.get && Number.isInteger(tabId)) { const tab = await chrome.tabs.get(tabId).catch(() => null); @@ -180,42 +240,42 @@ } windowId = normalizeAutomationWindowId(tab?.windowId); } - registry[source] = { + registry = setSourceMapValue(registry, source, { tabId, ready: true, ...(windowId !== null ? { windowId } : {}), - }; + }); await setState({ tabRegistry: registry }); console.log(LOG_PREFIX, `Tab registered: ${source} -> ${tabId}`); } async function isTabAlive(source) { - const registry = await getTabRegistry(); - const entry = registry[source]; + let registry = await getTabRegistry(); + const entry = getSourceMapValue(registry, source); if (!entry) return false; try { const tab = await chrome.tabs.get(entry.tabId); if (!(await isTabInAutomationWindow(tab))) { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return false; } return true; } catch { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return false; } } async function getTabId(source) { - const registry = await getTabRegistry(); - const tabId = registry[source]?.tabId || null; + let registry = await getTabRegistry(); + const tabId = getSourceMapValue(registry, source)?.tabId || null; if (!Number.isInteger(tabId)) { return null; } if (!(await isTabInAutomationWindow(tabId))) { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return null; } @@ -225,8 +285,7 @@ async function rememberSourceLastUrl(source, url) { if (!source || !url) return; const state = await getState(); - const sourceLastUrls = { ...(state.sourceLastUrls || {}) }; - sourceLastUrls[source] = url; + const sourceLastUrls = setSourceMapValue(state.sourceLastUrls, source, url); await setState({ sourceLastUrls }); } @@ -234,7 +293,7 @@ const { excludeTabIds = [] } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const state = await getState(); - const lastUrl = state.sourceLastUrls?.[source]; + const lastUrl = getSourceMapValue(state.sourceLastUrls, source); const referenceUrls = [currentUrl, lastUrl].filter(Boolean); if (!referenceUrls.length) return; @@ -249,9 +308,10 @@ await chrome.tabs.remove(matchedIds).catch(() => { }); - const registry = await getTabRegistry(); - if (registry[source]?.tabId && matchedIds.includes(registry[source].tabId)) { - registry[source] = null; + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); + if (sourceEntry?.tabId && matchedIds.includes(sourceEntry.tabId)) { + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); } @@ -274,7 +334,7 @@ async function closeLocalhostCallbackTabs(callbackUrl, options = {}) { if (!isLocalhostOAuthCallbackUrl(callbackUrl)) return 0; - const { excludeTabIds = [] } = options; + const { excludeTabIds = [], ownerSource = getCleanupOwnerSource('oauth-localhost-callback') } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const tabs = await queryTabsInAutomationWindow({}); const matchedIds = tabs @@ -286,9 +346,10 @@ await chrome.tabs.remove(matchedIds).catch(() => { }); - const registry = await getTabRegistry(); - if (registry['signup-page']?.tabId && matchedIds.includes(registry['signup-page'].tabId)) { - registry['signup-page'] = null; + let registry = await getTabRegistry(); + const ownerEntry = getSourceMapValue(registry, ownerSource); + if (ownerEntry?.tabId && matchedIds.includes(ownerEntry.tabId)) { + registry = setSourceMapValue(registry, ownerSource, null); await setState({ tabRegistry: registry }); } @@ -468,7 +529,7 @@ while (Date.now() - start < timeoutMs) { attempt += 1; const pong = await pingContentScriptOnTab(tabId); - if (pong?.ok && (!pong.source || pong.source === source)) { + if (pong?.ok && (!pong.source || sourcesMatch(pong.source, source))) { console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`); await registerTab(source, tabId); return; @@ -478,9 +539,13 @@ throw new Error(`${getSourceLabel(source)} 内容脚本未就绪,且未提供可用的注入文件。`); } - const registry = await getTabRegistry(); - if (registry[source]) { - registry[source].ready = false; + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); await setState({ tabRegistry: registry }); } @@ -505,7 +570,7 @@ } const pongAfterInject = await pingContentScriptOnTab(tabId); - if (pongAfterInject?.ok && (!pongAfterInject.source || pongAfterInject.source === source)) { + if (pongAfterInject?.ok && (!pongAfterInject.source || sourcesMatch(pongAfterInject.source, source))) { console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready after inject ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`); await registerTab(source, tabId); return; @@ -609,14 +674,16 @@ function queueCommand(source, message, timeout = 15000) { return new Promise((resolve, reject) => { + const commandKey = getSourceCommandKey(source); const timer = setTimeout(() => { - pendingCommands.delete(source); + pendingCommands.delete(commandKey); reject(new Error(`Content script on ${source} did not respond in ${timeout / 1000}s. Try refreshing the tab and retry.`)); }, timeout); - pendingCommands.set(source, { + pendingCommands.set(commandKey, { message, resolve, reject, + source, timer, responseTimeoutMs: timeout, }); @@ -625,11 +692,16 @@ } function flushCommand(source, tabId) { - const pending = pendingCommands.get(source); + const pending = pendingCommands.get(getSourceCommandKey(source)); if (pending) { clearTimeout(pending.timer); - pendingCommands.delete(source); - sendTabMessageWithTimeout(tabId, source, pending.message, pending.responseTimeoutMs).then(pending.resolve).catch(pending.reject); + pendingCommands.delete(getSourceCommandKey(source)); + sendTabMessageWithTimeout( + tabId, + pending.source || source, + pending.message, + pending.responseTimeoutMs + ).then(pending.resolve).catch(pending.reject); console.log(LOG_PREFIX, `Flushed queued command to ${source} (tab ${tabId})`); } } @@ -677,18 +749,29 @@ const sameUrl = currentTab.url === url; const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl; - const registry = await getTabRegistry(); + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); if (sameUrl) { await chrome.tabs.update(tabId, { active: true }); if (shouldReloadOnReuse) { - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); await chrome.tabs.reload(tabId); await waitForTabUpdateComplete(tabId); } if (options.inject) { - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); if (options.injectSource) { await chrome.scripting.executeScript({ @@ -710,7 +793,12 @@ return tabId; } - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); await chrome.tabs.update(tabId, { url, active: true }); @@ -765,7 +853,7 @@ throwIfStopped(); const { responseTimeoutMs = getContentScriptResponseTimeoutMs(message) } = options; const registry = await getTabRegistry(); - const entry = registry[source]; + const entry = getSourceMapValue(registry, source); if (!entry || !entry.ready) { throwIfStopped(); diff --git a/background/verification-flow.js b/background/verification-flow.js index 25fcb3d..dcfb066 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -7,6 +7,7 @@ function createVerificationFlowHelpers(deps = {}) { const { addLog: rawAddLog = async () => {}, + buildVerificationPollPayload: externalBuildVerificationPollPayload = null, chrome, closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, @@ -408,6 +409,9 @@ } function getVerificationPollPayload(step, state, overrides = {}) { + if (typeof externalBuildVerificationPollPayload === 'function') { + return externalBuildVerificationPollPayload(step, state, overrides); + } const is2925Provider = state?.mailProvider === '2925'; const mail2925MatchTargetEmail = is2925Provider && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; diff --git a/content/utils.js b/content/utils.js index 4a35f3e..8e749c3 100644 --- a/content/utils.js +++ b/content/utils.js @@ -7,8 +7,16 @@ function detectScriptSource({ url = '', hostname = '', } = {}) { + const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.(); + if (sourceRegistry?.detectSourceFromLocation) { + return sourceRegistry.detectSourceFromLocation({ + injectedSource, + url, + hostname, + }); + } if (injectedSource) return injectedSource; - if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page'; + if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'openai-auth'; if (hostname === 'mail.qq.com' || hostname === 'wx.mail.qq.com') return 'qq-mail'; if ( hostname === 'mail.163.com' @@ -22,8 +30,7 @@ function detectScriptSource({ if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail'; if (url.includes('chatgpt.com')) return 'chatgpt'; if (url.includes("2925.com")) return "mail-2925"; - // VPS panel — detected dynamically since URL is configurable - return 'vps-panel'; + return 'unknown-source'; } const SCRIPT_SOURCE = (() => { @@ -294,6 +301,10 @@ function log(message, level = 'info', options = {}) { * Report that this content script is loaded and ready. */ function reportReady() { + if (getRuntimeScriptSource() === 'unknown-source') { + console.warn(LOG_PREFIX, 'skip CONTENT_SCRIPT_READY for unknown source'); + return; + } console.log(LOG_PREFIX, '内容脚本已就绪'); const message = { type: 'CONTENT_SCRIPT_READY', @@ -439,6 +450,10 @@ async function humanPause(min = 250, max = 850) { } function shouldReportReadyForFrame(source, isChildFrame) { + const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.(); + if (sourceRegistry?.shouldReportReadyForFrame) { + return sourceRegistry.shouldReportReadyForFrame(source, isChildFrame); + } if (!isChildFrame) return true; return ![ 'qq-mail', @@ -447,6 +462,7 @@ function shouldReportReadyForFrame(source, isChildFrame) { 'mail-2925', 'inbucket-mail', 'plus-checkout', + 'unknown-source', ].includes(source); } diff --git a/flows/openai/mail-rules.js b/flows/openai/mail-rules.js new file mode 100644 index 0000000..940e80e --- /dev/null +++ b/flows/openai/mail-rules.js @@ -0,0 +1,69 @@ +(function attachOpenAiMailRules(root, factory) { + root.MultiPageOpenAiMailRules = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiMailRulesModule() { + const SIGNUP_CODE_RULE_ID = 'openai-signup-code'; + const LOGIN_CODE_RULE_ID = 'openai-login-code'; + + function createOpenAiMailRules(deps = {}) { + const { + getHotmailVerificationRequestTimestamp = () => 0, + MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, + } = deps; + + function isMail2925Provider(state = {}) { + return String(state?.mailProvider || '').trim().toLowerCase() === '2925'; + } + + function shouldMatchMail2925TargetEmail(state = {}) { + return isMail2925Provider(state) + && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; + } + + function getRuleDefinition(step, state = {}) { + const normalizedStep = Number(step) === 4 ? 4 : 8; + const mail2925Provider = isMail2925Provider(state); + const signupStep = normalizedStep === 4; + + return { + flowId: 'openai', + ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID, + step: normalizedStep, + artifactType: 'code', + filterAfterTimestamp: mail2925Provider + ? 0 + : getHotmailVerificationRequestTimestamp(normalizedStep, state), + senderFilters: signupStep + ? ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'] + : ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], + subjectFilters: signupStep + ? ['verify', 'verification', 'code', '验证码', 'confirm'] + : ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], + targetEmail: signupStep + ? state?.email + : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email), + mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), + maxAttempts: mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, + intervalMs: mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, + }; + } + + function buildVerificationPollPayload(step, state = {}, overrides = {}) { + return { + ...getRuleDefinition(step, state), + ...(overrides || {}), + }; + } + + return { + buildVerificationPollPayload, + getRuleDefinition, + }; + } + + return { + LOGIN_CODE_RULE_ID, + SIGNUP_CODE_RULE_ID, + createOpenAiMailRules, + }; +}); diff --git a/manifest.json b/manifest.json index f79b796..2d53725 100644 --- a/manifest.json +++ b/manifest.json @@ -49,6 +49,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/operation-delay.js", "content/auth-page-recovery.js", @@ -65,6 +66,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/qq-mail.js" ], @@ -81,6 +83,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/mail-163.js" ], @@ -94,6 +97,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/icloud-mail.js" ], @@ -106,6 +110,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/operation-delay.js", "content/duck-mail.js" diff --git a/shared/flow-capabilities.js b/shared/flow-capabilities.js new file mode 100644 index 0000000..8946645 --- /dev/null +++ b/shared/flow-capabilities.js @@ -0,0 +1,242 @@ +(function attachMultiPageFlowCapabilities(root, factory) { + root.MultiPageFlowCapabilities = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createFlowCapabilitiesModule() { + const DEFAULT_FLOW_ID = 'openai'; + const DEFAULT_PANEL_MODE = 'cpa'; + const SIGNUP_METHOD_EMAIL = 'email'; + const SIGNUP_METHOD_PHONE = 'phone'; + const VALID_PANEL_MODES = Object.freeze(['cpa', 'sub2api', 'codex2api']); + + const DEFAULT_FLOW_CAPABILITIES = Object.freeze({ + supportsEmailSignup: true, + supportsPhoneSignup: false, + supportsPhoneVerificationSettings: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsPlatformBinding: [], + supportsLuckmail: false, + supportsOauthTimeoutBudget: false, + canSwitchFlow: false, + stepDefinitionMode: 'default', + }); + + const FLOW_CAPABILITIES = Object.freeze({ + openai: Object.freeze({ + ...DEFAULT_FLOW_CAPABILITIES, + supportsPhoneSignup: true, + supportsPhoneVerificationSettings: true, + supportsPlusMode: true, + supportsContributionMode: true, + supportsPlatformBinding: ['cpa', 'sub2api', 'codex2api'], + supportsLuckmail: true, + supportsOauthTimeoutBudget: true, + stepDefinitionMode: 'openai-dynamic', + }), + }); + + const DEFAULT_PANEL_CAPABILITIES = Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }); + + const PANEL_CAPABILITIES = Object.freeze({ + cpa: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: true, + }), + sub2api: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }), + codex2api: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }), + }); + + function normalizeFlowId(value = '', fallback = DEFAULT_FLOW_ID) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return fallbackValue || DEFAULT_FLOW_ID; + } + + function normalizePanelMode(value = '', fallback = DEFAULT_PANEL_MODE) { + const normalized = String(value || '').trim().toLowerCase(); + if (VALID_PANEL_MODES.includes(normalized)) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return VALID_PANEL_MODES.includes(fallbackValue) ? fallbackValue : DEFAULT_PANEL_MODE; + } + + function normalizeSignupMethod(value = '') { + return String(value || '').trim().toLowerCase() === SIGNUP_METHOD_PHONE + ? SIGNUP_METHOD_PHONE + : SIGNUP_METHOD_EMAIL; + } + + function normalizePanelModeList(values = []) { + if (!Array.isArray(values)) { + return []; + } + const seen = new Set(); + const normalized = []; + values.forEach((value) => { + const mode = normalizePanelMode(value, ''); + if (!mode || seen.has(mode)) { + return; + } + seen.add(mode); + normalized.push(mode); + }); + return normalized; + } + + function createFlowCapabilityRegistry(deps = {}) { + const { + defaultFlowCapabilities = DEFAULT_FLOW_CAPABILITIES, + defaultFlowId = DEFAULT_FLOW_ID, + defaultPanelCapabilities = DEFAULT_PANEL_CAPABILITIES, + flowCapabilities = FLOW_CAPABILITIES, + panelCapabilities = PANEL_CAPABILITIES, + } = deps; + + function getFlowCapabilities(flowId) { + const normalizedFlowId = normalizeFlowId(flowId, defaultFlowId); + const entry = flowCapabilities[normalizedFlowId] || null; + return { + ...defaultFlowCapabilities, + ...(entry || {}), + supportsPlatformBinding: normalizePanelModeList(entry?.supportsPlatformBinding || defaultFlowCapabilities.supportsPlatformBinding), + }; + } + + function getPanelCapabilities(panelMode) { + const normalizedPanelMode = normalizePanelMode(panelMode); + return { + ...defaultPanelCapabilities, + ...(panelCapabilities[normalizedPanelMode] || {}), + }; + } + + function resolveSidepanelCapabilities(options = {}) { + const state = options?.state || {}; + const activeFlowId = normalizeFlowId( + options?.activeFlowId ?? state?.activeFlowId, + defaultFlowId + ); + const flowState = getFlowCapabilities(activeFlowId); + const requestedPanelMode = normalizePanelMode( + options?.panelMode ?? state?.panelMode, + DEFAULT_PANEL_MODE + ); + const supportedPanelModes = normalizePanelModeList(flowState.supportsPlatformBinding); + const panelModeSupported = supportedPanelModes.length === 0 + ? true + : supportedPanelModes.includes(requestedPanelMode); + const effectivePanelMode = panelModeSupported + ? requestedPanelMode + : supportedPanelModes[0]; + const panelState = getPanelCapabilities(effectivePanelMode); + const runtimeLocks = { + autoRunLocked: Boolean(options?.autoRunLocked ?? state?.autoRunLocked), + contributionMode: flowState.supportsContributionMode && Boolean(state?.contributionMode), + phoneVerificationEnabled: flowState.supportsPhoneVerificationSettings && Boolean(state?.phoneVerificationEnabled), + plusModeEnabled: flowState.supportsPlusMode && Boolean(state?.plusModeEnabled), + settingsMenuLocked: Boolean(options?.settingsMenuLocked ?? state?.settingsMenuLocked), + }; + const effectiveSignupMethods = []; + if (flowState.supportsEmailSignup !== false) { + effectiveSignupMethods.push(SIGNUP_METHOD_EMAIL); + } + const canSelectPhoneSignup = Boolean(flowState.supportsPhoneSignup) + && Boolean(panelState.supportsPhoneSignup) + && runtimeLocks.phoneVerificationEnabled + && !runtimeLocks.plusModeEnabled + && !runtimeLocks.contributionMode; + if (canSelectPhoneSignup) { + effectiveSignupMethods.push(SIGNUP_METHOD_PHONE); + } + if (!effectiveSignupMethods.length) { + effectiveSignupMethods.push(SIGNUP_METHOD_EMAIL); + } + const requestedSignupMethod = normalizeSignupMethod( + options?.signupMethod ?? state?.signupMethod + ); + const effectiveSignupMethod = requestedSignupMethod === SIGNUP_METHOD_PHONE && canSelectPhoneSignup + ? SIGNUP_METHOD_PHONE + : (effectiveSignupMethods.includes(SIGNUP_METHOD_EMAIL) + ? SIGNUP_METHOD_EMAIL + : effectiveSignupMethods[0]); + + return { + activeFlowId, + canShowContributionMode: Boolean(flowState.supportsContributionMode), + canShowLuckmail: Boolean(flowState.supportsLuckmail), + canShowPhoneSettings: Boolean(flowState.supportsPhoneVerificationSettings), + canShowPlusSettings: Boolean(flowState.supportsPlusMode), + canSwitchFlow: Boolean(flowState.canSwitchFlow), + canUsePhoneSignup: canSelectPhoneSignup, + canUseSelectedPanelMode: panelModeSupported, + effectivePanelMode, + effectiveSignupMethod, + effectiveSignupMethods, + flowCapabilities: flowState, + panelCapabilities: panelState, + panelMode: effectivePanelMode, + requestedPanelMode, + requestedSignupMethod, + runtimeLocks, + shouldWarnCpaPhoneSignup: effectiveSignupMethod === SIGNUP_METHOD_PHONE + && Boolean(panelState.requiresPhoneSignupWarning), + stepDefinitionOptions: { + activeFlowId, + panelMode: effectivePanelMode, + plusModeEnabled: runtimeLocks.plusModeEnabled, + signupMethod: effectiveSignupMethod, + }, + supportedPanelModes, + }; + } + + function canUsePhoneSignup(state = {}) { + return resolveSidepanelCapabilities({ state }).canUsePhoneSignup; + } + + function resolveSignupMethod(state = {}, signupMethod = undefined) { + return resolveSidepanelCapabilities({ + signupMethod, + state, + }).effectiveSignupMethod; + } + + return { + canUsePhoneSignup, + getFlowCapabilities, + getPanelCapabilities, + normalizeFlowId, + normalizePanelMode, + normalizeSignupMethod, + resolveSidepanelCapabilities, + resolveSignupMethod, + }; + } + + return { + createFlowCapabilityRegistry, + DEFAULT_FLOW_CAPABILITIES, + DEFAULT_FLOW_ID, + DEFAULT_PANEL_CAPABILITIES, + DEFAULT_PANEL_MODE, + FLOW_CAPABILITIES, + PANEL_CAPABILITIES, + SIGNUP_METHOD_EMAIL, + SIGNUP_METHOD_PHONE, + normalizeFlowId, + normalizePanelMode, + normalizeSignupMethod, + }; +}); diff --git a/shared/source-registry.js b/shared/source-registry.js new file mode 100644 index 0000000..6d7ce66 --- /dev/null +++ b/shared/source-registry.js @@ -0,0 +1,433 @@ +(function attachMultiPageSourceRegistry(root, factory) { + root.MultiPageSourceRegistry = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createSourceRegistryModule() { + const SOURCE_ALIASES = Object.freeze({ + 'signup-page': 'openai-auth', + }); + + const SOURCE_DEFINITIONS = Object.freeze({ + 'openai-auth': { + flowId: 'openai', + kind: 'flow-page', + label: '认证页', + readyPolicy: 'allow-child-frame', + family: 'openai-auth-family', + driverId: 'content/signup-page', + cleanupScopes: ['oauth-localhost-callback'], + }, + chatgpt: { + flowId: 'openai', + kind: 'flow-entry', + label: 'ChatGPT 首页', + readyPolicy: 'allow-child-frame', + family: 'chatgpt-entry-family', + driverId: null, + cleanupScopes: [], + }, + 'qq-mail': { + flowId: null, + kind: 'mail-provider', + label: 'QQ 邮箱', + readyPolicy: 'top-frame-only', + family: 'qq-mail-family', + driverId: 'content/qq-mail', + cleanupScopes: [], + }, + 'mail-163': { + flowId: null, + kind: 'mail-provider', + label: '163 邮箱', + readyPolicy: 'top-frame-only', + family: 'mail-163-family', + driverId: 'content/mail-163', + cleanupScopes: [], + }, + 'gmail-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Gmail 邮箱', + readyPolicy: 'top-frame-only', + family: 'gmail-mail-family', + driverId: 'content/gmail-mail', + cleanupScopes: [], + }, + 'icloud-mail': { + flowId: null, + kind: 'mail-provider', + label: 'iCloud 邮箱', + readyPolicy: 'allow-child-frame', + family: 'icloud-mail-family', + driverId: 'content/icloud-mail', + cleanupScopes: [], + }, + 'inbucket-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Inbucket 邮箱', + readyPolicy: 'top-frame-only', + family: 'inbucket-mail-family', + driverId: 'content/inbucket-mail', + cleanupScopes: [], + }, + 'mail-2925': { + flowId: null, + kind: 'mail-provider', + label: '2925 邮箱', + readyPolicy: 'top-frame-only', + family: 'mail-2925-family', + driverId: 'content/mail-2925', + cleanupScopes: [], + }, + 'duck-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Duck 邮箱', + readyPolicy: 'allow-child-frame', + family: 'duck-mail-family', + driverId: 'content/duck-mail', + cleanupScopes: [], + }, + 'vps-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'CPA 面板', + readyPolicy: 'allow-child-frame', + family: 'vps-panel-family', + driverId: 'content/vps-panel', + cleanupScopes: [], + }, + 'sub2api-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'SUB2API 后台', + readyPolicy: 'allow-child-frame', + family: 'sub2api-panel-family', + driverId: 'content/sub2api-panel', + cleanupScopes: [], + }, + 'codex2api-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'Codex2API 后台', + readyPolicy: 'allow-child-frame', + family: 'codex2api-panel-family', + driverId: 'content/sub2api-panel', + cleanupScopes: [], + }, + 'plus-checkout': { + flowId: 'openai', + kind: 'flow-page', + label: 'Plus Checkout', + readyPolicy: 'top-frame-only', + family: 'plus-checkout-family', + driverId: 'content/plus-checkout', + cleanupScopes: [], + }, + 'paypal-flow': { + flowId: 'openai', + kind: 'flow-page', + label: 'PayPal 授权页', + readyPolicy: 'allow-child-frame', + family: 'paypal-flow-family', + driverId: 'content/paypal-flow', + cleanupScopes: [], + }, + 'gopay-flow': { + flowId: 'openai', + kind: 'flow-page', + label: 'GoPay 授权页', + readyPolicy: 'allow-child-frame', + family: 'gopay-flow-family', + driverId: 'content/gopay-flow', + cleanupScopes: [], + }, + 'unknown-source': { + flowId: null, + kind: 'unknown', + label: '未知来源', + readyPolicy: 'disabled', + family: 'unknown-family', + driverId: null, + cleanupScopes: [], + }, + }); + + const DRIVER_DEFINITIONS = Object.freeze({ + 'content/signup-page': { + sourceId: 'openai-auth', + commands: [ + 'OPEN_SIGNUP', + 'SUBMIT_SIGNUP_IDENTIFIER', + 'SUBMIT_PASSWORD', + 'SUBMIT_PROFILE', + 'SUBMIT_LOGIN_CODE', + 'SUBMIT_PHONE_CODE', + 'DETECT_AUTH_STATE', + ], + }, + 'content/qq-mail': { + sourceId: 'qq-mail', + commands: ['POLL_EMAIL'], + }, + 'content/mail-163': { + sourceId: 'mail-163', + commands: ['POLL_EMAIL'], + }, + 'content/gmail-mail': { + sourceId: 'gmail-mail', + commands: ['POLL_EMAIL'], + }, + 'content/icloud-mail': { + sourceId: 'icloud-mail', + commands: ['POLL_EMAIL'], + }, + 'content/mail-2925': { + sourceId: 'mail-2925', + commands: ['POLL_EMAIL'], + }, + 'content/duck-mail': { + sourceId: 'duck-mail', + commands: ['FETCH_ALIAS_EMAIL'], + }, + 'content/sub2api-panel': { + sourceId: 'sub2api-panel', + commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL', 'VERIFY_PLATFORM'], + }, + 'content/vps-panel': { + sourceId: 'vps-panel', + commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL'], + }, + 'content/plus-checkout': { + sourceId: 'plus-checkout', + commands: ['CREATE_CHECKOUT', 'FILL_CHECKOUT'], + }, + 'content/paypal-flow': { + sourceId: 'paypal-flow', + commands: ['APPROVE_PAYPAL'], + }, + 'content/gopay-flow': { + sourceId: 'gopay-flow', + commands: ['APPROVE_GOPAY'], + }, + }); + + const CLEANUP_SCOPE_OWNERS = Object.freeze({ + 'oauth-localhost-callback': 'openai-auth', + }); + + const AUTH_PAGE_HOSTS = new Set(['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com']); + const ENTRY_PAGE_HOSTS = new Set(['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com']); + const CHILD_FRAME_BLOCKED_SOURCES = new Set([ + 'qq-mail', + 'mail-163', + 'gmail-mail', + 'mail-2925', + 'inbucket-mail', + 'plus-checkout', + ]); + + function createSourceRegistry() { + function parseUrlSafely(rawUrl) { + if (!rawUrl) return null; + try { + return new URL(rawUrl); + } catch { + return null; + } + } + + function normalizeSourceId(source) { + return String(source || '').trim(); + } + + function resolveCanonicalSource(source) { + const normalized = normalizeSourceId(source); + if (!normalized) return ''; + return SOURCE_ALIASES[normalized] || normalized; + } + + function getAliasKeysForCanonicalSource(source) { + const canonical = resolveCanonicalSource(source); + return Object.keys(SOURCE_ALIASES).filter((alias) => SOURCE_ALIASES[alias] === canonical); + } + + function getSourceKeys(source) { + const normalized = normalizeSourceId(source); + const canonical = resolveCanonicalSource(normalized); + return Array.from(new Set([ + canonical, + ...getAliasKeysForCanonicalSource(canonical), + normalized, + ].filter(Boolean))); + } + + function getSourceMeta(source) { + const canonical = resolveCanonicalSource(source); + const definition = SOURCE_DEFINITIONS[canonical]; + if (!definition) { + return null; + } + return { + id: canonical, + aliases: getAliasKeysForCanonicalSource(canonical), + ...definition, + }; + } + + function getSourceLabel(source) { + return getSourceMeta(source)?.label || normalizeSourceId(source) || '未知来源'; + } + + function getDriverIdForSource(source) { + return getSourceMeta(source)?.driverId || null; + } + + function getDriverMeta(sourceOrDriverId) { + const directDriverId = normalizeSourceId(sourceOrDriverId); + const driverId = Object.prototype.hasOwnProperty.call(DRIVER_DEFINITIONS, directDriverId) + ? directDriverId + : getDriverIdForSource(sourceOrDriverId); + if (!driverId || !Object.prototype.hasOwnProperty.call(DRIVER_DEFINITIONS, driverId)) { + return null; + } + return { + id: driverId, + ...DRIVER_DEFINITIONS[driverId], + }; + } + + function isSignupPageHost(hostname = '') { + return AUTH_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); + } + + function isSignupEntryHost(hostname = '') { + return ENTRY_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); + } + + function is163MailHost(hostname = '') { + const normalized = String(hostname || '').toLowerCase(); + return normalized === 'mail.163.com' + || normalized.endsWith('.mail.163.com') + || normalized === 'mail.126.com' + || normalized.endsWith('.mail.126.com') + || normalized === 'webmail.vip.163.com'; + } + + function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { + const candidate = parseUrlSafely(candidateUrl); + if (!candidate) return false; + + const canonical = resolveCanonicalSource(source); + const reference = parseUrlSafely(referenceUrl); + + switch (canonical) { + case 'openai-auth': + return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); + case 'chatgpt': + return isSignupEntryHost(candidate.hostname); + case 'duck-mail': + return candidate.hostname === 'duckduckgo.com' && candidate.pathname.startsWith('/email/'); + case 'qq-mail': + return candidate.hostname === 'mail.qq.com' || candidate.hostname === 'wx.mail.qq.com'; + case 'mail-163': + return is163MailHost(candidate.hostname); + case 'gmail-mail': + return candidate.hostname === 'mail.google.com'; + case 'icloud-mail': + return candidate.hostname === 'www.icloud.com' + || candidate.hostname === 'www.icloud.com.cn'; + case 'inbucket-mail': + return Boolean(reference) + && candidate.origin === reference.origin + && candidate.pathname.startsWith('/m/'); + case 'mail-2925': + return candidate.hostname === '2925.com' || candidate.hostname === 'www.2925.com'; + case 'vps-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && candidate.pathname === reference.pathname; + case 'sub2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname.startsWith('/login') + || candidate.pathname === '/' + ); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname === '/admin' + || candidate.pathname === '/' + ); + case 'plus-checkout': + return candidate.hostname === 'chatgpt.com' + && candidate.pathname.startsWith('/checkout/'); + case 'paypal-flow': + return candidate.hostname.endsWith('paypal.com'); + case 'gopay-flow': + return /gopay|gojek/i.test(candidate.hostname); + default: + return false; + } + } + + function detectSourceFromLocation({ + injectedSource, + url = '', + hostname = '', + } = {}) { + if (injectedSource) return resolveCanonicalSource(injectedSource); + + const normalizedHostname = String(hostname || '').toLowerCase(); + const normalizedUrl = String(url || ''); + + if (isSignupPageHost(normalizedHostname)) return 'openai-auth'; + if (normalizedHostname === 'mail.qq.com' || normalizedHostname === 'wx.mail.qq.com') return 'qq-mail'; + if (is163MailHost(normalizedHostname)) return 'mail-163'; + if (normalizedHostname === 'mail.google.com') return 'gmail-mail'; + if (normalizedHostname === 'www.icloud.com' || normalizedHostname === 'www.icloud.com.cn') return 'icloud-mail'; + if (normalizedUrl.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail'; + if (normalizedUrl.includes('2925.com')) return 'mail-2925'; + if (isSignupEntryHost(normalizedHostname)) return 'chatgpt'; + return 'unknown-source'; + } + + function shouldReportReadyForFrame(source, isChildFrame) { + const canonical = resolveCanonicalSource(source); + const readyPolicy = getSourceMeta(canonical)?.readyPolicy || 'allow-child-frame'; + if (readyPolicy === 'disabled') return false; + if (!isChildFrame) return true; + if (readyPolicy === 'top-frame-only') return false; + if (CHILD_FRAME_BLOCKED_SOURCES.has(canonical)) return false; + return true; + } + + function getCleanupOwnerSource(cleanupScope) { + return resolveCanonicalSource(CLEANUP_SCOPE_OWNERS[String(cleanupScope || '').trim()] || ''); + } + + return { + detectSourceFromLocation, + getCleanupOwnerSource, + getDriverIdForSource, + getDriverMeta, + getSourceKeys, + getSourceLabel, + getSourceMeta, + is163MailHost, + isSignupEntryHost, + isSignupPageHost, + matchesSourceUrlFamily, + parseUrlSafely, + resolveCanonicalSource, + shouldReportReadyForFrame, + }; + } + + return { + createSourceRegistry, + }; +}); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index f26087c..9b70f74 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -1732,6 +1732,7 @@ + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 7e7c5b3..99453c1 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -517,6 +517,7 @@ const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL; const SIGNUP_METHOD_EMAIL = 'email'; const SIGNUP_METHOD_PHONE = 'phone'; const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL; +const DEFAULT_ACTIVE_FLOW_ID = 'openai'; let currentPlusModeEnabled = false; let currentPlusPaymentMethod = DEFAULT_PLUS_PAYMENT_METHOD; let currentSignupMethod = DEFAULT_SIGNUP_METHOD; @@ -1931,6 +1932,39 @@ function shouldWarnCpaPhoneSignup(signupMethod = null, panelMode = null) { ) ); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: typeof latestState !== 'undefined' ? latestState?.activeFlowId : '', + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + }, + }) + : null; + })(); + + if (capabilityState && typeof capabilityState.shouldWarnCpaPhoneSignup === 'boolean') { + return capabilityState.shouldWarnCpaPhoneSignup && !isCpaPhoneSignupPromptDismissed(); + } + return resolvedSignupMethod === SIGNUP_METHOD_PHONE && resolvedPanelMode === 'cpa' && !isCpaPhoneSignupPromptDismissed(); @@ -3470,6 +3504,57 @@ function collectSettingsPayload() { ? normalizeSignupMethod(latestState?.signupMethod) : (String(latestState?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email')) ); + const normalizePanelModeSafe = typeof normalizePanelMode === 'function' + ? normalizePanelMode + : ((value = '') => { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'sub2api' || normalized === 'codex2api' ? normalized : 'cpa'; + }); + const rawPanelMode = normalizePanelModeSafe(selectPanelMode?.value || latestState?.panelMode || 'cpa'); + const rawPlusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled); + const rawPhoneVerificationEnabled = Boolean(inputPhoneVerificationEnabled?.checked); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: rawPanelMode, + signupMethod: selectedSignupMethod, + state: { + ...(latestState || {}), + panelMode: rawPanelMode, + plusModeEnabled: rawPlusModeEnabled, + phoneVerificationEnabled: rawPhoneVerificationEnabled, + signupMethod: selectedSignupMethod, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: latestState?.activeFlowId, + panelMode: rawPanelMode, + signupMethod: selectedSignupMethod, + state: { + ...(latestState || {}), + panelMode: rawPanelMode, + plusModeEnabled: rawPlusModeEnabled, + phoneVerificationEnabled: rawPhoneVerificationEnabled, + signupMethod: selectedSignupMethod, + }, + }) + : null; + })(); + const effectivePanelMode = capabilityState?.effectivePanelMode || capabilityState?.panelMode || rawPanelMode; + const effectivePlusModeEnabled = capabilityState + ? Boolean(capabilityState.runtimeLocks?.plusModeEnabled) + : rawPlusModeEnabled; + const effectivePhoneVerificationEnabled = capabilityState + ? Boolean(capabilityState.runtimeLocks?.phoneVerificationEnabled) + : rawPhoneVerificationEnabled; + const effectiveSignupMethod = capabilityState?.effectiveSignupMethod || selectedSignupMethod; const plusPaymentMethod = getSelectedPlusPaymentMethod(); const normalizeGpcHelperPhoneModeSafe = typeof normalizeGpcHelperPhoneModeValue === 'function' ? normalizeGpcHelperPhoneModeValue @@ -3527,7 +3612,7 @@ function collectSettingsPayload() { }); return { ...(contributionModeEnabled ? {} : { - panelMode: selectPanelMode.value, + panelMode: effectivePanelMode, }), vpsUrl: inputVpsUrl.value.trim(), vpsPassword: inputVpsPassword.value, @@ -3565,9 +3650,7 @@ function collectSettingsPayload() { ipProxyRegion: currentIpProxyServiceProfile.region, codex2apiUrl: inputCodex2ApiUrl.value.trim(), codex2apiAdminKey: inputCodex2ApiAdminKey.value.trim(), - plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled - ? Boolean(inputPlusModeEnabled.checked) - : Boolean(latestState?.plusModeEnabled), + plusModeEnabled: effectivePlusModeEnabled, plusPaymentMethod, paypalEmail: String(currentPayPalAccount?.email || latestState?.paypalEmail || '').trim(), paypalPassword: String(currentPayPalAccount?.password || latestState?.paypalPassword || ''), @@ -3682,8 +3765,8 @@ function collectSettingsPayload() { oauthFlowTimeoutEnabled: typeof inputOAuthFlowTimeoutEnabled !== 'undefined' && inputOAuthFlowTimeoutEnabled ? Boolean(inputOAuthFlowTimeoutEnabled.checked) : true, - phoneVerificationEnabled: Boolean(inputPhoneVerificationEnabled?.checked), - signupMethod: selectedSignupMethod, + phoneVerificationEnabled: effectivePhoneVerificationEnabled, + signupMethod: effectiveSignupMethod, phoneSmsProvider: phoneSmsProviderValue, phoneSmsProviderOrder: phoneSmsProviderOrderValue, verificationResendCount: normalizeVerificationResendCount( @@ -7215,11 +7298,66 @@ function normalizePanelMode(value = '') { return 'cpa'; } +let flowCapabilityRegistry = null; + +function getFlowCapabilityRegistry() { + if (flowCapabilityRegistry) { + return flowCapabilityRegistry; + } + const rootScope = typeof window !== 'undefined' ? window : globalThis; + flowCapabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, + }) || null; + return flowCapabilityRegistry; +} + +function resolveCurrentSidepanelCapabilities(options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.resolveSidepanelCapabilities) { + return null; + } + const state = { + ...(latestState || {}), + ...(options?.state || {}), + }; + return registry.resolveSidepanelCapabilities({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + +function resolveStepDefinitionCapabilityState(state = latestState, options = {}) { + const nextState = { + ...(state || {}), + ...(options?.state || {}), + }; + const capabilityState = resolveCurrentSidepanelCapabilities({ + activeFlowId: options?.activeFlowId ?? nextState?.activeFlowId, + panelMode: options?.panelMode ?? nextState?.panelMode, + signupMethod: options?.signupMethod ?? nextState?.signupMethod, + state: nextState, + }); + return { + capabilityState, + plusModeEnabled: capabilityState + ? Boolean(capabilityState.runtimeLocks?.plusModeEnabled) + : Boolean(nextState?.plusModeEnabled), + signupMethod: capabilityState?.effectiveSignupMethod + || normalizeSignupMethod((options?.signupMethod ?? nextState?.signupMethod) || DEFAULT_SIGNUP_METHOD), + }; +} + function getSelectedPanelMode() { const selectedValue = typeof selectPanelMode !== 'undefined' && selectPanelMode ? selectPanelMode.value : (typeof latestState !== 'undefined' ? latestState?.panelMode : ''); - return normalizePanelMode(selectedValue || 'cpa'); + const resolvedPanelMode = normalizePanelMode(selectedValue || 'cpa'); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ panelMode: resolvedPanelMode }) + : null; + return capabilityState?.effectivePanelMode || capabilityState?.panelMode || resolvedPanelMode; } function getSelectedSignupMethod() { @@ -7244,6 +7382,37 @@ function canSelectPhoneSignupMethod() { ? Boolean(inputPlusModeEnabled.checked) : Boolean(latestState?.plusModeEnabled); const contributionModeEnabled = Boolean(latestState?.contributionMode); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + phoneVerificationEnabled: phoneEnabled, + plusModeEnabled, + contributionMode: contributionModeEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: typeof latestState !== 'undefined' ? latestState?.activeFlowId : '', + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : (latestState?.panelMode || 'cpa'), + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + phoneVerificationEnabled: phoneEnabled, + plusModeEnabled, + contributionMode: contributionModeEnabled, + }, + }) + : null; + })(); + if (capabilityState && typeof capabilityState.canSelectPhoneSignup === 'boolean') { + return capabilityState.canSelectPhoneSignup; + } return phoneEnabled && !plusModeEnabled && !contributionModeEnabled; } @@ -7295,22 +7464,68 @@ function updateSignupMethodUI(options = {}) { } } }); - syncStepDefinitionsForMode( - typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled - ? Boolean(inputPlusModeEnabled.checked) - : Boolean(latestState?.plusModeEnabled), - { - plusPaymentMethod: getSelectedPlusPaymentMethod(latestState), + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled), signupMethod: selectedMethod, - } - ); + }, { + signupMethod: selectedMethod, + }) + : { + plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled), + signupMethod: selectedMethod, + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { + plusPaymentMethod: getSelectedPlusPaymentMethod(latestState), + signupMethod: selectedMethod, + }); if (typeof syncSignupPhoneInputFromState === 'function') { syncSignupPhoneInputFromState(latestState); } } function updatePhoneVerificationSettingsUI() { - const enabled = Boolean(inputPhoneVerificationEnabled?.checked); + const rawEnabled = Boolean(inputPhoneVerificationEnabled?.checked); + const rawPlusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + signupMethod: typeof getSelectedSignupMethod === 'function' ? getSelectedSignupMethod() : latestState?.signupMethod, + state: { + ...(latestState || {}), + phoneVerificationEnabled: rawEnabled, + plusModeEnabled: rawPlusModeEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: latestState?.activeFlowId, + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : (latestState?.panelMode || 'cpa'), + signupMethod: typeof getSelectedSignupMethod === 'function' ? getSelectedSignupMethod() : latestState?.signupMethod, + state: { + ...(latestState || {}), + phoneVerificationEnabled: rawEnabled, + plusModeEnabled: rawPlusModeEnabled, + }, + }) + : null; + })(); + const canShowPhoneSettings = capabilityState + ? Boolean(capabilityState.canShowPhoneSettings) + : true; + const enabled = canShowPhoneSettings && rawEnabled; const showSettings = enabled && phoneVerificationSectionExpanded; const normalizeProvider = typeof normalizePhoneSmsProviderValue === 'function' ? normalizePhoneSmsProviderValue @@ -7333,10 +7548,10 @@ function updatePhoneVerificationSettingsUI() { const fiveSimProvider = provider === fiveSimProviderValue; const nexSmsProvider = provider === nexSmsProviderValue; if (rowPhoneVerificationEnabled) { - rowPhoneVerificationEnabled.style.display = ''; + rowPhoneVerificationEnabled.style.display = canShowPhoneSettings ? '' : 'none'; } if (rowHeroSmsPlatform) { - rowHeroSmsPlatform.style.display = ''; + rowHeroSmsPlatform.style.display = canShowPhoneSettings ? '' : 'none'; } updateSignupMethodUI(); if (btnTogglePhoneVerificationSection) { @@ -7447,9 +7662,37 @@ function updatePlusModeUI() { const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; const gpcValue = typeof PLUS_PAYMENT_METHOD_GPC_HELPER !== 'undefined' ? PLUS_PAYMENT_METHOD_GPC_HELPER : 'gpc-helper'; const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : paypalValue; - const enabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + const rawEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled ? Boolean(inputPlusModeEnabled.checked) : false; + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + state: { + ...(latestState || {}), + plusModeEnabled: rawEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: latestState?.activeFlowId, + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : (latestState?.panelMode || 'cpa'), + state: { + ...(latestState || {}), + plusModeEnabled: rawEnabled, + }, + }) + : null; + })(); + const supportsPlusMode = capabilityState + ? Boolean(capabilityState.canShowPlusSettings) + : true; + const enabled = supportsPlusMode && rawEnabled; const method = enabled ? getSelectedPlusPaymentMethod() : defaultMethod; const gpcPhoneMode = normalizeGpcHelperPhoneModeValue( typeof selectGpcHelperPhoneMode !== 'undefined' && selectGpcHelperPhoneMode @@ -7476,6 +7719,9 @@ function updatePlusModeUI() { const canShowGpcModeSelector = gpcRowsVisible; const localSmsControlsVisible = gpcRowsVisible && !isGpcAutoMode; const effectiveLocalSmsEnabled = !isGpcAutoMode && localSmsEnabled; + if (typeof rowPlusMode !== 'undefined' && rowPlusMode) { + rowPlusMode.style.display = supportsPlusMode ? '' : 'none'; + } if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) { selectPlusPaymentMethod.value = method; if (selectPlusPaymentMethod.style) { @@ -7657,7 +7903,39 @@ function syncSignupPhoneInputFromState(state = latestState) { const selectedMethod = typeof normalizeSignupMethod === 'function' ? normalizeSignupMethod(rawSignupMethod) : (String(rawSignupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'); - rowSignupPhone.style.display = phoneVerificationEnabled + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: state?.panelMode || latestState?.panelMode, + signupMethod: selectedMethod, + state: { + ...(latestState || {}), + ...(state || {}), + phoneVerificationEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId || latestState?.activeFlowId, + panelMode: state?.panelMode || latestState?.panelMode, + signupMethod: selectedMethod, + state: { + ...(latestState || {}), + ...(state || {}), + phoneVerificationEnabled, + }, + }) + : null; + })(); + const canShowPhoneSettings = capabilityState + ? Boolean(capabilityState.canShowPhoneSettings) + : true; + rowSignupPhone.style.display = canShowPhoneSettings + && phoneVerificationEnabled && (selectedMethod === 'phone' || Boolean(signupPhone) || Boolean(getSignupPhoneInputValue()) || signupPhoneInputDirty) ? '' : 'none'; @@ -8285,9 +8563,17 @@ function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOr function applySettingsState(state) { if (typeof syncStepDefinitionsForMode === 'function') { - syncStepDefinitionsForMode(Boolean(state?.plusModeEnabled), { + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState(state, { + signupMethod: state?.signupMethod, + }) + : { + plusModeEnabled: Boolean(state?.plusModeEnabled), + signupMethod: normalizeSignupMethod(state?.signupMethod || DEFAULT_SIGNUP_METHOD), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { plusPaymentMethod: state?.plusPaymentMethod, - signupMethod: state?.signupMethod, + signupMethod: stepDefinitionState.signupMethod, }); } const fallbackIpProxyService = '711proxy'; @@ -9776,6 +10062,30 @@ function updateMailProviderUI() { const icloudHostPreferenceValue = typeof selectIcloudHostPreference !== 'undefined' ? selectIcloudHostPreference?.value : latestState?.icloudHostPreference; + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + state: latestState || {}, + }) + : null; + const canShowLuckmail = capabilityState + ? Boolean(capabilityState.canShowLuckmail) + : true; + const mailProviderOptions = Array.from(selectMailProvider?.options || []); + mailProviderOptions.forEach((option) => { + if (!option) { + return; + } + if (String(option.value || '').trim().toLowerCase() === 'luckmail-api') { + option.hidden = !canShowLuckmail; + } + }); + if (!canShowLuckmail && String(selectMailProvider?.value || '').trim().toLowerCase() === 'luckmail-api') { + const fallbackOption = mailProviderOptions.find((option) => option && !option.hidden); + if (fallbackOption) { + selectMailProvider.value = String(fallbackOption.value || '').trim(); + } + } const use2925 = selectMailProvider.value === '2925'; const useGmail = selectMailProvider.value === GMAIL_PROVIDER; const useMail2925 = selectMailProvider.value === '2925'; @@ -9806,7 +10116,7 @@ function updateMailProviderUI() { const useGeneratedAlias = usesGeneratedAliasMailProvider(selectMailProvider.value, mail2925Mode, selectedGenerator); const useInbucket = selectMailProvider.value === 'inbucket'; const useHotmail = selectMailProvider.value === 'hotmail-api'; - const useLuckmail = isLuckmailProvider(); + const useLuckmail = canShowLuckmail && isLuckmailProvider(); const useCustomEmail = isCustomMailProvider(); const useCustomMailProviderPool = useCustomEmail && usesCustomMailProviderPool(selectMailProvider.value); const useIcloudProvider = isIcloudMailProvider(); @@ -10228,7 +10538,39 @@ async function handleDeleteSub2ApiGroup(groupName) { } function updatePanelModeUI() { - const panelMode = getSelectedPanelMode(); + const rawPanelMode = normalizePanelMode(selectPanelMode?.value || latestState?.panelMode || 'cpa'); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: rawPanelMode, + state: { + ...(latestState || {}), + panelMode: rawPanelMode, + }, + }) + : null; + const supportedPanelModes = Array.isArray(capabilityState?.supportedPanelModes) + ? capabilityState.supportedPanelModes + : []; + if (selectPanelMode?.options && supportedPanelModes.length) { + Array.from(selectPanelMode.options).forEach((option) => { + if (!option) { + return; + } + const optionMode = normalizePanelMode(option.value || ''); + const enabled = supportedPanelModes.includes(optionMode); + option.disabled = !enabled; + option.hidden = !enabled; + }); + } else if (selectPanelMode?.options) { + Array.from(selectPanelMode.options).forEach((option) => { + if (!option) { + return; + } + option.disabled = false; + option.hidden = false; + }); + } + const panelMode = capabilityState?.effectivePanelMode || capabilityState?.panelMode || getSelectedPanelMode(); if (selectPanelMode) { selectPanelMode.value = panelMode; } @@ -11799,7 +12141,22 @@ inputPassword.addEventListener('blur', () => { inputPlusModeEnabled?.addEventListener('change', () => { updatePlusModeUI(); updateSignupMethodUI({ notify: true }); - syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled.checked), getSelectedPlusPaymentMethod(), { render: true }); + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: Boolean(inputPlusModeEnabled.checked), + signupMethod: getSelectedSignupMethod(), + }, { + signupMethod: getSelectedSignupMethod(), + }) + : { + plusModeEnabled: Boolean(inputPlusModeEnabled.checked), + signupMethod: getSelectedSignupMethod(), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, getSelectedPlusPaymentMethod(), { + render: true, + signupMethod: stepDefinitionState.signupMethod, + }); markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); }); @@ -11811,7 +12168,22 @@ inputOperationDelayEnabled?.addEventListener('change', () => { selectPlusPaymentMethod?.addEventListener('change', () => { selectPlusPaymentMethod.value = normalizePlusPaymentMethod(selectPlusPaymentMethod.value); updatePlusModeUI(); - syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled?.checked), selectPlusPaymentMethod.value, { render: true }); + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }, { + signupMethod: getSelectedSignupMethod(), + }) + : { + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, selectPlusPaymentMethod.value, { + render: true, + signupMethod: stepDefinitionState.signupMethod, + }); markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); }); @@ -11873,9 +12245,22 @@ btnGpcHelperBalance?.addEventListener('click', async () => { selectPlusPaymentMethod?.addEventListener('change', () => { updatePlusModeUI(); - syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled?.checked), { + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }, { + signupMethod: getSelectedSignupMethod(), + }) + : { + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { render: true, plusPaymentMethod: selectPlusPaymentMethod.value, + signupMethod: stepDefinitionState.signupMethod, }); markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); @@ -12011,7 +12396,9 @@ checkboxAutoDeleteIcloud?.addEventListener('change', () => { selectPanelMode.addEventListener('change', async () => { const previousPanelMode = normalizePanelMode(latestState?.panelMode || 'cpa'); - const nextPanelMode = normalizePanelMode(selectPanelMode.value); + const rawNextPanelMode = normalizePanelMode(selectPanelMode.value); + selectPanelMode.value = rawNextPanelMode; + const nextPanelMode = getSelectedPanelMode(); selectPanelMode.value = nextPanelMode; const confirmed = await confirmCpaPhoneSignupIfNeeded({ signupMethod: getSelectedSignupMethod(), @@ -13845,10 +14232,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { || message.payload.gopayHelperOtpChannel !== undefined || message.payload.gopayHelperLocalSmsHelperEnabled !== undefined ) { + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState(latestState, { + signupMethod: latestState?.signupMethod, + }) + : { + plusModeEnabled: Boolean(latestState?.plusModeEnabled), + signupMethod: normalizeSignupMethod(latestState?.signupMethod || DEFAULT_SIGNUP_METHOD), + }; syncStepDefinitionsForMode( - Boolean(latestState?.plusModeEnabled), + stepDefinitionState.plusModeEnabled, latestState?.plusPaymentMethod, - { render: true } + { + render: true, + signupMethod: stepDefinitionState.signupMethod, + } ); updatePlusModeUI(); updateSignupMethodUI({ notify: true }); diff --git a/tests/background-luckmail.test.js b/tests/background-luckmail.test.js index 669cd28..e9deb50 100644 --- a/tests/background-luckmail.test.js +++ b/tests/background-luckmail.test.js @@ -731,6 +731,9 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c 'async function getPersistedAliasState() {', ' return {};', '}', + 'function buildStatePatchWithRuntimeState(_currentState, updates) {', + ' return updates;', + '}', 'const chrome = {', ' storage: {', ' session: {', diff --git a/tests/background-mail-rule-registry-module.test.js b/tests/background-mail-rule-registry-module.test.js new file mode 100644 index 0000000..d6cc6c0 --- /dev/null +++ b/tests/background-mail-rule-registry-module.test.js @@ -0,0 +1,93 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('background imports mail rule registry and OpenAI mail rules modules', () => { + const source = fs.readFileSync('background.js', 'utf8'); + assert.match(source, /background\/mail-rule-registry\.js/); + assert.match(source, /flows\/openai\/mail-rules\.js/); +}); + +test('mail rule registry exposes canonical OpenAI verification poll payloads', () => { + const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8'); + const openAiSource = fs.readFileSync('flows/openai/mail-rules.js', 'utf8'); + const registryApi = new Function('self', `${registrySource}; return self.MultiPageBackgroundMailRuleRegistry;`)({}); + const openAiApi = new Function('self', `${openAiSource}; return self.MultiPageOpenAiMailRules;`)({}); + + const openAiMailRules = openAiApi.createOpenAiMailRules({ + getHotmailVerificationRequestTimestamp: (step) => (step === 4 ? 123 : 456), + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + }); + const registry = registryApi.createMailRuleRegistry({ + defaultFlowId: 'openai', + flowBuilders: { + openai: openAiMailRules, + }, + }); + + assert.deepEqual( + registry.buildVerificationPollPayload( + 4, + { + activeFlowId: 'openai', + email: 'user@example.com', + mailProvider: '2925', + mail2925Mode: 'receive', + }, + { excludeCodes: ['111111'] } + ), + { + flowId: 'openai', + ruleId: 'openai-signup-code', + step: 4, + artifactType: 'code', + filterAfterTimestamp: 0, + senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], + subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], + targetEmail: 'user@example.com', + mail2925MatchTargetEmail: true, + maxAttempts: 15, + intervalMs: 15000, + excludeCodes: ['111111'], + } + ); + + assert.deepEqual( + registry.buildVerificationPollPayload(8, { + activeFlowId: 'openai', + email: 'user@example.com', + step8VerificationTargetEmail: 'login@example.com', + }), + { + flowId: 'openai', + ruleId: 'openai-login-code', + step: 8, + artifactType: 'code', + filterAfterTimestamp: 456, + senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], + subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], + targetEmail: 'login@example.com', + mail2925MatchTargetEmail: false, + maxAttempts: 5, + intervalMs: 3000, + } + ); +}); + +test('mail rule registry rejects unknown active flow ids instead of silently using OpenAI rules', () => { + const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8'); + const registryApi = new Function('self', `${registrySource}; return self.MultiPageBackgroundMailRuleRegistry;`)({}); + const registry = registryApi.createMailRuleRegistry({ + defaultFlowId: 'openai', + flowBuilders: {}, + }); + + assert.throws( + () => registry.buildVerificationPollPayload(4, { + activeFlowId: 'site-a', + email: 'user@example.com', + }), + /未找到 flow=site-a 的邮件轮询规则构造器/ + ); +}); diff --git a/tests/background-message-router-module.test.js b/tests/background-message-router-module.test.js index fef97fd..f1a2da0 100644 --- a/tests/background-message-router-module.test.js +++ b/tests/background-message-router-module.test.js @@ -162,3 +162,39 @@ test('SAVE_SETTING broadcasts operation delay setting without background success assert.deepStrictEqual(broadcasts.at(-1), { operationDelayEnabled: false }); assert.equal(logs.length, 0); }); + +test('SAVE_SETTING re-resolves signup method when panel mode changes', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = { console }; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + let state = { + signupMethod: 'phone', + phoneVerificationEnabled: true, + plusModeEnabled: false, + panelMode: 'sub2api', + }; + + const router = api.createMessageRouter({ + addLog: async () => {}, + buildLuckmailSessionSettingsPayload: () => ({}), + buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'panelMode') + ? { panelMode: input.panelMode } + : {}, + broadcastDataUpdate: () => {}, + getState: async () => ({ ...state }), + resolveSignupMethod: (nextState = {}) => nextState.panelMode === 'cpa' ? 'email' : 'phone', + setPersistentSettings: async () => {}, + setState: async (updates) => { + state = { ...state, ...updates }; + }, + }); + + const response = await router.handleMessage({ + type: 'SAVE_SETTING', + payload: { panelMode: 'cpa' }, + }); + + assert.equal(response.ok, true); + assert.equal(state.panelMode, 'cpa'); + assert.equal(state.signupMethod, 'email'); +}); diff --git a/tests/background-navigation-utils-module.test.js b/tests/background-navigation-utils-module.test.js index eee918b..cce18ed 100644 --- a/tests/background-navigation-utils-module.test.js +++ b/tests/background-navigation-utils-module.test.js @@ -29,6 +29,7 @@ test('navigation utils recognize signup password pages for email and phone signu assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/create-account/password'), true); assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in/password'), true); assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in'), false); + assert.equal(utils.isSignupEntryHost('www.chatgpt.com'), true); }); test('navigation utils treat 126 mail hosts as part of the shared NetEase mail family', () => { diff --git a/tests/background-runtime-state-module.test.js b/tests/background-runtime-state-module.test.js new file mode 100644 index 0000000..df67866 --- /dev/null +++ b/tests/background-runtime-state-module.test.js @@ -0,0 +1,163 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadRuntimeStateApi() { + const source = fs.readFileSync('background/runtime-state.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundRuntimeState;`)(globalScope); +} + +test('background imports runtime-state module and wires state view helpers', () => { + const source = fs.readFileSync('background.js', 'utf8'); + assert.match(source, /background\/runtime-state\.js/); + assert.match(source, /createRuntimeStateHelpers/); + assert.match(source, /buildStateViewWithRuntimeState/); + assert.match(source, /buildStatePatchWithRuntimeState/); + assert.match(source, /runtimeState:/); +}); + +test('runtime-state module exposes a factory', () => { + const api = loadRuntimeStateApi(); + assert.equal(typeof api?.createRuntimeStateHelpers, 'function'); +}); + +test('runtime-state view derives canonical flow metadata from legacy step state', () => { + const api = loadRuntimeStateApi(); + const helpers = api.createRuntimeStateHelpers({ + DEFAULT_ACTIVE_FLOW_ID: 'openai', + defaultStepStatuses: { + 1: 'pending', + 2: 'pending', + 10: 'pending', + }, + getStepDefinitionForState(step) { + return { + 1: { id: 1, key: 'open-chatgpt' }, + 2: { id: 2, key: 'submit-signup-email' }, + 10: { id: 10, key: 'oauth-login' }, + }[Number(step)] || null; + }, + }); + + const view = helpers.buildStateView({ + currentStep: 2, + stepStatuses: { + 1: 'completed', + 2: 'running', + }, + oauthUrl: 'https://auth.example.com/start', + plusCheckoutTabId: 88, + currentPhoneActivation: { + activationId: 'active-1', + phoneNumber: '+447700900123', + }, + tabRegistry: { + 'signup-page': { tabId: 12 }, + }, + sourceLastUrls: { + 'signup-page': 'https://auth.example.com/start', + }, + flowStartTime: 12345, + }); + + assert.equal(view.activeFlowId, 'openai'); + assert.equal(view.currentNodeId, 'submit-signup-email'); + assert.deepStrictEqual(view.legacyStepCompat, { + currentStep: 2, + stepStatuses: { + 1: 'completed', + 2: 'running', + 10: 'pending', + }, + }); + assert.deepStrictEqual(view.nodeStatuses, { + 'open-chatgpt': 'completed', + 'submit-signup-email': 'running', + 'oauth-login': 'pending', + }); + assert.equal(view.runtimeState.flowState.openai.auth.oauthUrl, 'https://auth.example.com/start'); + assert.equal(view.runtimeState.flowState.openai.plus.plusCheckoutTabId, 88); + assert.deepStrictEqual(view.runtimeState.flowState.openai.phoneVerification.currentPhoneActivation, { + activationId: 'active-1', + phoneNumber: '+447700900123', + }); + assert.deepStrictEqual(view.sharedState, { + tabRegistry: { + 'signup-page': { tabId: 12 }, + }, + sourceLastUrls: { + 'signup-page': 'https://auth.example.com/start', + }, + flowStartTime: 12345, + }); +}); + +test('runtime-state patch accepts nested flow updates while keeping legacy compatibility fields in sync', () => { + const api = loadRuntimeStateApi(); + const helpers = api.createRuntimeStateHelpers({ + DEFAULT_ACTIVE_FLOW_ID: 'openai', + defaultStepStatuses: { + 1: 'pending', + 2: 'pending', + 10: 'pending', + }, + getStepDefinitionForState(step) { + return { + 1: { id: 1, key: 'open-chatgpt' }, + 2: { id: 2, key: 'submit-signup-email' }, + 10: { id: 10, key: 'oauth-login' }, + }[Number(step)] || null; + }, + }); + + const patch = helpers.buildSessionStatePatch({ + currentStep: 1, + stepStatuses: { + 1: 'running', + 2: 'pending', + 10: 'pending', + }, + oauthUrl: 'https://old.example.com/start', + }, { + runtimeState: { + activeRunId: 'run-001', + flowState: { + openai: { + auth: { + oauthUrl: 'https://new.example.com/start', + }, + plus: { + plusCheckoutTabId: 99, + }, + }, + }, + legacyStepCompat: { + currentStep: 10, + stepStatuses: { + 1: 'completed', + 10: 'running', + }, + }, + }, + }); + + assert.equal(patch.activeFlowId, 'openai'); + assert.equal(patch.activeRunId, 'run-001'); + assert.equal(patch.currentNodeId, 'oauth-login'); + assert.equal(patch.oauthUrl, 'https://new.example.com/start'); + assert.equal(patch.plusCheckoutTabId, 99); + assert.equal(patch.currentStep, 10); + assert.deepStrictEqual(patch.stepStatuses, { + 1: 'completed', + 2: 'pending', + 10: 'running', + }); + assert.deepStrictEqual(patch.nodeStatuses, { + 'open-chatgpt': 'completed', + 'submit-signup-email': 'pending', + 'oauth-login': 'running', + }); + assert.equal(patch.runtimeState.flowState.openai.auth.oauthUrl, 'https://new.example.com/start'); + assert.equal(patch.runtimeState.flowState.openai.plus.plusCheckoutTabId, 99); +}); diff --git a/tests/background-signup-method-settings.test.js b/tests/background-signup-method-settings.test.js index 25d61c1..3af827a 100644 --- a/tests/background-signup-method-settings.test.js +++ b/tests/background-signup-method-settings.test.js @@ -95,6 +95,51 @@ return { assert.equal(api.logs.some((entry) => /固定为邮箱注册/.test(entry.message)), true); }); +test('signup method resolution respects the shared flow capability registry when available', () => { + const api = new Function(` +const self = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + resolveSidepanelCapabilities({ state = {}, signupMethod = 'email' } = {}) { + const phoneAllowed = String(state?.activeFlowId || '').trim().toLowerCase() === 'openai'; + return { + canUsePhoneSignup: phoneAllowed, + effectiveSignupMethod: signupMethod === 'phone' && phoneAllowed ? 'phone' : 'email', + }; + }, + }; + }, + }, +}; +const SIGNUP_METHOD_EMAIL = 'email'; +const SIGNUP_METHOD_PHONE = 'phone'; +${extractFunction('normalizeSignupMethod')} +${extractFunction('canUsePhoneSignup')} +${extractFunction('resolveSignupMethod')} +return { + canUsePhoneSignup, + resolveSignupMethod, +}; +`)(); + + assert.equal(api.canUsePhoneSignup({ + activeFlowId: 'site-a', + phoneVerificationEnabled: true, + signupMethod: 'phone', + }), false); + assert.equal(api.resolveSignupMethod({ + activeFlowId: 'site-a', + phoneVerificationEnabled: true, + signupMethod: 'phone', + }), 'email'); + assert.equal(api.resolveSignupMethod({ + activeFlowId: 'openai', + phoneVerificationEnabled: true, + signupMethod: 'phone', + }), 'phone'); +}); + test('background step definitions resolve titles from the frozen signup method', () => { const api = new Function(` const captured = []; diff --git a/tests/background-tab-runtime-module.test.js b/tests/background-tab-runtime-module.test.js index b1186bd..6a31394 100644 --- a/tests/background-tab-runtime-module.test.js +++ b/tests/background-tab-runtime-module.test.js @@ -16,6 +16,76 @@ test('tab runtime module exposes a factory', () => { assert.equal(typeof api?.createTabRuntime, 'function'); }); +test('tab runtime accepts canonical openai-auth readiness for queued signup-page commands', async () => { + const runtimeSource = fs.readFileSync('background/tab-runtime.js', 'utf8'); + const registrySource = fs.readFileSync('shared/source-registry.js', 'utf8'); + const runtimeApi = new Function('self', `${runtimeSource}; return self.MultiPageBackgroundTabRuntime;`)({}); + const registryApi = new Function('self', `${registrySource}; return self.MultiPageSourceRegistry;`)({}); + const sourceRegistry = registryApi.createSourceRegistry(); + + const sentMessages = []; + let currentState = { + tabRegistry: { + 'signup-page': { tabId: 91, ready: true }, + }, + sourceLastUrls: { + 'signup-page': 'https://auth.openai.com/authorize', + }, + }; + + const runtime = runtimeApi.createTabRuntime({ + LOG_PREFIX: '[test]', + addLog: async () => {}, + chrome: { + tabs: { + get: async (tabId) => ({ + id: tabId, + windowId: 1, + url: 'https://auth.openai.com/authorize', + status: 'complete', + }), + query: async () => [], + sendMessage: async (tabId, message) => { + if (message.type === 'PING') { + return { ok: true, source: 'openai-auth' }; + } + sentMessages.push({ tabId, message }); + return { ok: true }; + }, + }, + }, + getSourceLabel: (source) => source || 'unknown', + getState: async () => currentState, + matchesSourceUrlFamily: (source, candidateUrl, referenceUrl) => ( + sourceRegistry.matchesSourceUrlFamily(source, candidateUrl, referenceUrl) + ), + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sourceRegistry, + throwIfStopped: () => {}, + }); + + assert.equal(await runtime.getTabId('openai-auth'), 91); + + currentState = { + tabRegistry: {}, + sourceLastUrls: {}, + }; + + const queued = runtime.queueCommand('signup-page', { type: 'STEP2_TEST' }, 1000); + runtime.flushCommand('openai-auth', 55); + await assert.doesNotReject(queued); + assert.deepEqual(sentMessages, [{ tabId: 55, message: { type: 'STEP2_TEST' } }]); + + await runtime.ensureContentScriptReadyOnTab('signup-page', 77, { + timeoutMs: 100, + }); + + assert.deepEqual(currentState.tabRegistry['openai-auth'], { tabId: 77, ready: true, windowId: 1 }); + assert.equal(Object.prototype.hasOwnProperty.call(currentState.tabRegistry, 'signup-page'), false); +}); + test('tab runtime caps per-attempt response timeout to the remaining resilient timeout budget', () => { const source = fs.readFileSync('background/tab-runtime.js', 'utf8'); const globalScope = {}; diff --git a/tests/content-utils.test.js b/tests/content-utils.test.js index 8703fdd..44353c8 100644 --- a/tests/content-utils.test.js +++ b/tests/content-utils.test.js @@ -81,6 +81,38 @@ return { detectScriptSource }; ); }); +test('detectScriptSource maps OpenAI auth hosts to canonical openai-auth source', () => { + const bundle = [extractFunction('detectScriptSource')].join('\n'); + const api = new Function(` +${bundle} +return { detectScriptSource }; +`)(); + + assert.equal( + api.detectScriptSource({ + url: 'https://auth.openai.com/create-account', + hostname: 'auth.openai.com', + }), + 'openai-auth' + ); +}); + +test('detectScriptSource returns unknown-source for unrecognized pages', () => { + const bundle = [extractFunction('detectScriptSource')].join('\n'); + const api = new Function(` +${bundle} +return { detectScriptSource }; +`)(); + + assert.equal( + api.detectScriptSource({ + url: 'https://example.com/', + hostname: 'example.com', + }), + 'unknown-source' + ); +}); + test('shouldReportReadyForFrame suppresses noisy plus checkout child frame ready logs', () => { const bundle = [extractFunction('shouldReportReadyForFrame')].join('\n'); const api = new Function(` @@ -91,6 +123,8 @@ return { shouldReportReadyForFrame }; assert.equal(api.shouldReportReadyForFrame('plus-checkout', true), false); assert.equal(api.shouldReportReadyForFrame('plus-checkout', false), true); assert.equal(api.shouldReportReadyForFrame('paypal-flow', true), true); + assert.equal(api.shouldReportReadyForFrame('unknown-source', false), true); + assert.equal(api.shouldReportReadyForFrame('unknown-source', true), false); }); test('getRuntimeScriptSource follows injected source overrides after utils is already loaded', () => { diff --git a/tests/flow-capabilities-module.test.js b/tests/flow-capabilities-module.test.js new file mode 100644 index 0000000..e6d8125 --- /dev/null +++ b/tests/flow-capabilities-module.test.js @@ -0,0 +1,72 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('shared/flow-capabilities.js', 'utf8'); + +function loadApi() { + const scope = {}; + return new Function('self', `${source}; return self.MultiPageFlowCapabilities;`)(scope); +} + +test('flow capability registry keeps OpenAI phone signup available only when runtime locks allow it', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry(); + + const enabledState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'openai', + panelMode: 'cpa', + phoneVerificationEnabled: true, + plusModeEnabled: false, + contributionMode: false, + signupMethod: 'phone', + }, + }); + + assert.equal(enabledState.canUsePhoneSignup, true); + assert.equal(enabledState.effectiveSignupMethod, 'phone'); + assert.equal(enabledState.shouldWarnCpaPhoneSignup, true); + assert.deepEqual(enabledState.effectiveSignupMethods, ['email', 'phone']); + + const plusLockedState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'openai', + panelMode: 'sub2api', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: false, + signupMethod: 'phone', + }, + }); + + assert.equal(plusLockedState.canUsePhoneSignup, false); + assert.equal(plusLockedState.effectiveSignupMethod, 'email'); + assert.equal(plusLockedState.shouldWarnCpaPhoneSignup, false); + assert.deepEqual(plusLockedState.effectiveSignupMethods, ['email']); +}); + +test('flow capability registry defaults unknown flows to minimal non-phone capabilities', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry(); + + const capabilityState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'site-a', + panelMode: 'codex2api', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: true, + signupMethod: 'phone', + }, + }); + + assert.equal(capabilityState.activeFlowId, 'site-a'); + assert.equal(capabilityState.canShowPhoneSettings, false); + assert.equal(capabilityState.canShowPlusSettings, false); + assert.equal(capabilityState.canShowLuckmail, false); + assert.equal(capabilityState.canUsePhoneSignup, false); + assert.equal(capabilityState.effectiveSignupMethod, 'email'); + assert.equal(capabilityState.panelMode, 'codex2api'); + assert.deepEqual(capabilityState.supportedPanelModes, []); +}); diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js index 6488369..774983e 100644 --- a/tests/sidepanel-phone-verification-settings.test.js +++ b/tests/sidepanel-phone-verification-settings.test.js @@ -294,6 +294,58 @@ return { assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), false); }); +test('sidepanel phone signup gating can follow the shared flow capability registry', () => { + const bundle = [ + extractFunction('normalizeSignupMethod'), + extractFunction('normalizePanelMode'), + extractFunction('canSelectPhoneSignupMethod'), + extractFunction('shouldWarnCpaPhoneSignup'), + ].join('\n'); + + const api = new Function(` +const window = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + resolveSidepanelCapabilities({ state = {}, panelMode = 'cpa', signupMethod = 'email' } = {}) { + const phoneAllowed = String(state?.activeFlowId || '').trim().toLowerCase() === 'openai'; + return { + canSelectPhoneSignup: phoneAllowed, + shouldWarnCpaPhoneSignup: phoneAllowed && signupMethod === 'phone' && panelMode === 'cpa', + }; + }, + }; + }, + }, +}; +let latestState = { + activeFlowId: 'site-a', + contributionMode: false, + panelMode: 'cpa', +}; +const inputPhoneVerificationEnabled = { checked: true }; +const inputPlusModeEnabled = { checked: false }; +function getSelectedPanelMode() { return 'cpa'; } +function getSelectedSignupMethod() { return 'phone'; } +function isCpaPhoneSignupPromptDismissed() { return false; } +${bundle} +return { + canSelectPhoneSignupMethod, + shouldWarnCpaPhoneSignup, + setFlow(flowId) { + latestState.activeFlowId = flowId; + }, +}; +`)(); + + assert.equal(api.canSelectPhoneSignupMethod(), false); + assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), false); + + api.setFlow('openai'); + assert.equal(api.canSelectPhoneSignupMethod(), true); + assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), true); +}); + test('manual step 3 uses phone identity without requiring registration email', () => { const api = new Function(` let latestState = { signupMethod: 'phone', phoneVerificationEnabled: true, signupPhoneNumber: '+441111111111', accountIdentifierType: 'phone', accountIdentifier: '+441111111111' }; diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js index ded1b45..1a936be 100644 --- a/tests/sidepanel-plus-payment-method.test.js +++ b/tests/sidepanel-plus-payment-method.test.js @@ -124,8 +124,8 @@ test('sidepanel signup method UI syncs shared step definitions with the selected test('sidepanel applies restored signup method when rebuilding shared step definitions on load', () => { const source = extractFunction('applySettingsState'); - assert.match(source, /syncStepDefinitionsForMode\(Boolean\(state\?\.plusModeEnabled\),\s*\{/); - assert.match(source, /signupMethod:\s*state\?\.signupMethod/); + assert.match(source, /resolveStepDefinitionCapabilityState\(state/); + assert.match(source, /signupMethod:\s*stepDefinitionState\.signupMethod/); }); test('sidepanel Plus UI hides PayPal account selector while GoPay is selected', () => { @@ -165,6 +165,62 @@ return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount }; assert.equal(api.rowPayPalAccount.style.display, ''); }); +test('sidepanel Plus UI can hide Plus controls when the shared flow capability registry disables them', () => { + const bundle = [ + extractFunction('normalizePlusPaymentMethod'), + extractFunction('getSelectedPlusPaymentMethod'), + extractFunction('normalizeGpcHelperPhoneModeValue'), + extractFunction('getGpcHelperAutoModeEnabled'), + extractFunction('normalizeGpcAutoModePermissionValue'), + extractFunction('getGpcAutoModePermissionFromPayload'), + extractFunction('shouldPreserveSelectedGpcAutoMode'), + extractFunction('hasGpcAutoModePermissionField'), + extractFunction('isGpcAutoModePermissionDenied'), + extractFunction('normalizeGpcOtpChannelValue'), + extractFunction('updatePlusModeUI'), + ].join('\n'); + + const api = new Function(` +const window = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + resolveSidepanelCapabilities() { + return { + canShowPlusSettings: false, + runtimeLocks: { plusModeEnabled: false }, + }; + }, + }; + }, + }, +}; +let latestState = { plusPaymentMethod: 'paypal' }; +const inputPlusModeEnabled = { checked: true }; +const rowPlusMode = { style: { display: '' } }; +const selectPlusPaymentMethod = { value: 'paypal', style: { display: '' } }; +const rowPlusPaymentMethod = { style: { display: '' } }; +const rowPayPalAccount = { style: { display: '' } }; +const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; +const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; +${bundle} +return { + rowPlusMode, + rowPlusPaymentMethod, + rowPayPalAccount, + selectPlusPaymentMethod, + updatePlusModeUI, +}; +`)(); + + api.updatePlusModeUI(); + + assert.equal(api.rowPlusMode.style.display, 'none'); + assert.equal(api.rowPlusPaymentMethod.style.display, 'none'); + assert.equal(api.rowPayPalAccount.style.display, 'none'); + assert.equal(api.selectPlusPaymentMethod.style.display, 'none'); +}); + test('sidepanel step definitions keep GPC helper mode distinct', () => { const bundle = [ extractFunction('normalizeSignupMethod'), diff --git a/tests/source-registry-module.test.js b/tests/source-registry-module.test.js new file mode 100644 index 0000000..ae6d47c --- /dev/null +++ b/tests/source-registry-module.test.js @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('background imports shared source registry module', () => { + const source = fs.readFileSync('background.js', 'utf8'); + assert.match(source, /shared\/source-registry\.js/); +}); + +test('manifest loads shared source registry before content utils in static bundles', () => { + const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); + for (const entry of manifest.content_scripts || []) { + const scripts = Array.isArray(entry.js) ? entry.js : []; + if (!scripts.includes('content/utils.js')) continue; + assert.ok(scripts.includes('shared/source-registry.js')); + assert.ok( + scripts.indexOf('shared/source-registry.js') < scripts.indexOf('content/utils.js'), + 'shared/source-registry.js must load before content/utils.js' + ); + } +}); + +test('shared source registry exposes canonical source, alias, detection, and ready policies', () => { + const source = fs.readFileSync('shared/source-registry.js', 'utf8'); + const api = new Function('self', `${source}; return self.MultiPageSourceRegistry;`)({}); + const registry = api.createSourceRegistry(); + + assert.equal(registry.resolveCanonicalSource('signup-page'), 'openai-auth'); + assert.deepEqual(registry.getSourceKeys('signup-page'), ['openai-auth', 'signup-page']); + assert.equal(registry.getSourceLabel('openai-auth'), '认证页'); + assert.equal( + registry.matchesSourceUrlFamily( + 'openai-auth', + 'https://chatgpt.com/', + 'https://auth.openai.com/authorize?client_id=test' + ), + true + ); + assert.equal( + registry.detectSourceFromLocation({ + url: 'https://auth.openai.com/create-account', + hostname: 'auth.openai.com', + }), + 'openai-auth' + ); + assert.equal( + registry.detectSourceFromLocation({ + url: 'https://example.com/', + hostname: 'example.com', + }), + 'unknown-source' + ); + assert.equal(registry.shouldReportReadyForFrame('mail-163', true), false); + assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false); + assert.equal(registry.getCleanupOwnerSource('oauth-localhost-callback'), 'openai-auth'); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 14eedb8..9e5dc1e 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -9,6 +9,7 @@ const api = new Function('self', `${source}; return self.MultiPageBackgroundVeri function createVerificationFlowTestHelpers(overrides = {}) { return api.createVerificationFlowHelpers({ addLog: async () => {}, + buildVerificationPollPayload: null, chrome: { tabs: { update: async () => {}, @@ -43,6 +44,42 @@ function createVerificationFlowTestHelpers(overrides = {}) { }); } +test('verification flow prefers injected verification poll payload builder when provided', () => { + const helpers = createVerificationFlowTestHelpers({ + buildVerificationPollPayload: (step, state, overrides = {}) => ({ + flowId: state.activeFlowId || 'openai', + ruleId: 'custom-rule', + step, + senderFilters: ['custom-sender'], + subjectFilters: ['custom-subject'], + targetEmail: state.email, + maxAttempts: 9, + intervalMs: 4321, + ...overrides, + }), + }); + + assert.deepStrictEqual( + helpers.getVerificationPollPayload(4, { + activeFlowId: 'openai', + email: 'user@example.com', + }, { + excludeCodes: ['111111'], + }), + { + flowId: 'openai', + ruleId: 'custom-rule', + step: 4, + senderFilters: ['custom-sender'], + subjectFilters: ['custom-subject'], + targetEmail: 'user@example.com', + maxAttempts: 9, + intervalMs: 4321, + excludeCodes: ['111111'], + } + ); +}); + test('verification flow keeps 2925 polling cadence in the default payload', () => { const helpers = api.createVerificationFlowHelpers({ addLog: async () => {},