diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js index 6aed66f..81a1131 100644 --- a/background/contribution-oauth.js +++ b/background/contribution-oauth.js @@ -44,6 +44,7 @@ } = deps; let listenersBound = false; + const inFlightCapturedCallbackTasks = new Map(); function normalizeString(value = '') { return String(value || '').trim(); @@ -110,6 +111,29 @@ return FINAL_STATUSES.has(normalizeContributionStatus(status)); } + function runCapturedCallbackOnce(callbackUrl, executor) { + const normalizedUrl = normalizeString(callbackUrl); + if (!normalizedUrl) { + return Promise.resolve().then(executor); + } + + const existingTask = inFlightCapturedCallbackTasks.get(normalizedUrl); + if (existingTask) { + return existingTask; + } + + let task = null; + task = Promise.resolve() + .then(executor) + .finally(() => { + if (inFlightCapturedCallbackTasks.get(normalizedUrl) === task) { + inFlightCapturedCallbackTasks.delete(normalizedUrl); + } + }); + inFlightCapturedCallbackTasks.set(normalizedUrl, task); + return task; + } + function normalizeContributionSource(value = '') { const normalized = normalizeString(value).toLowerCase(); return normalized === CONTRIBUTION_SOURCE_SUB2API @@ -513,42 +537,44 @@ } async function handleCapturedCallback(rawUrl, metadata = {}) { - const currentState = await getState(); - if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { - return currentState; - } - if (!isContributionCallbackUrl(rawUrl, currentState)) { - return currentState; - } - const normalizedUrl = normalizeString(rawUrl); - const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); - if ( - normalizedUrl - && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl - && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') - ) { - return currentState; - } + return runCapturedCallbackOnce(normalizedUrl, async () => { + const currentState = await getState(); + if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { + return currentState; + } + if (!isContributionCallbackUrl(normalizedUrl, currentState)) { + return currentState; + } - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: 'captured', - contributionCallbackMessage: buildCallbackMessage('captured'), - }); + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if ( + normalizedUrl + && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl + && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') + ) { + return currentState; + } - if (typeof addLog === 'function') { - await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info'); - } - - try { - return await submitContributionCallback(normalizedUrl, { - reason: metadata.source || 'navigation', - stateOverride: await getState(), + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'captured', + contributionCallbackMessage: buildCallbackMessage('captured'), }); - } catch { - return getState(); - } + + if (typeof addLog === 'function') { + await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info'); + } + + try { + return await submitContributionCallback(normalizedUrl, { + reason: metadata.source || 'navigation', + stateOverride: await getState(), + }); + } catch { + return getState(); + } + }); } async function pollContributionStatus(options = {}) { @@ -696,7 +722,7 @@ } function onTabUpdated(tabId, changeInfo, tab) { - const candidateUrl = normalizeString(changeInfo?.url || tab?.url); + const candidateUrl = normalizeString(changeInfo?.url); if (!candidateUrl) { return; } diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index bf5defc..56ddcd6 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -510,6 +510,157 @@ test('contribution oauth manager starts session, opens auth url, submits callbac assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted')); }); +test('contribution oauth manager deduplicates concurrent callback captures for the same localhost url', async () => { + const source = fs.readFileSync('background/contribution-oauth.js', 'utf8'); + const globalScope = {}; + const fetchCalls = []; + const broadcasts = []; + let submitCallCount = 0; + let resolveSubmitRequest = null; + let currentState = { + contributionMode: true, + contributionSource: 'sub2api', + contributionTargetGroupName: 'codex号池', + contributionSessionId: 'session-001', + contributionAuthState: 'oauth-state-001', + contributionStatus: 'waiting', + contributionCallbackStatus: 'idle', + }; + + const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)( + globalScope, + async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (String(url).endsWith('/submit-callback')) { + submitCallCount += 1; + await new Promise((resolve) => { + resolveSubmitRequest = resolve; + }); + return createMockResponse(true, 200, { + ok: true, + session_id: 'session-001', + status: 'processing', + message: '回调地址已提交给 CPA,正在等待结果确认。', + }); + } + if (String(url).includes('/status?')) { + return createMockResponse(true, 200, { + ok: true, + session_id: 'session-001', + status: 'processing', + message: '回调地址已提交给 CPA,正在等待结果确认。', + }); + } + return createMockResponse(true, 200, { ok: true }); + } + ); + + const manager = api.createContributionOAuthManager({ + addLog: async () => {}, + broadcastDataUpdate(updates) { + broadcasts.push(updates); + currentState = { ...currentState, ...updates }; + }, + chrome: { + tabs: { + onUpdated: { addListener() {} }, + }, + webNavigation: { + onCommitted: { addListener() {} }, + onHistoryStateUpdated: { addListener() {} }, + }, + }, + getState: async () => currentState, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + }); + + const callbackUrl = 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001'; + const firstTask = manager.handleCapturedCallback(callbackUrl, { source: 'tabs.onUpdated' }); + const secondTask = manager.handleCapturedCallback(callbackUrl, { source: 'webNavigation.onCommitted' }); + + for (let round = 0; round < 10 && submitCallCount === 0; round += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + assert.equal(submitCallCount, 1); + assert.equal( + broadcasts.filter((entry) => entry.contributionCallbackStatus === 'captured').length, + 1 + ); + + assert.equal(typeof resolveSubmitRequest, 'function'); + resolveSubmitRequest(); + const [firstState, secondState] = await Promise.all([firstTask, secondTask]); + + assert.equal(firstState.contributionCallbackStatus, 'submitted'); + assert.equal(secondState.contributionCallbackStatus, 'submitted'); + assert.equal(fetchCalls.filter((call) => String(call.url).endsWith('/submit-callback')).length, 1); +}); + +test('contribution oauth manager ignores tabs.onUpdated events without a real url change', async () => { + const source = fs.readFileSync('background/contribution-oauth.js', 'utf8'); + const globalScope = {}; + const fetchCalls = []; + const addedListeners = {}; + let currentState = { + contributionMode: true, + contributionSource: 'sub2api', + contributionTargetGroupName: 'codex号池', + contributionSessionId: 'session-001', + contributionAuthState: 'oauth-state-001', + contributionStatus: 'waiting', + contributionCallbackStatus: 'idle', + }; + + const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)( + globalScope, + async (url, options = {}) => { + fetchCalls.push({ url, options }); + return createMockResponse(true, 200, { ok: true, status: 'processing' }); + } + ); + + const manager = api.createContributionOAuthManager({ + addLog: async () => {}, + broadcastDataUpdate(updates) { + currentState = { ...currentState, ...updates }; + }, + chrome: { + tabs: { + onUpdated: { + addListener(listener) { + addedListeners.onTabUpdated = listener; + }, + }, + }, + webNavigation: { + onCommitted: { addListener() {} }, + onHistoryStateUpdated: { addListener() {} }, + }, + }, + getState: async () => currentState, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + }); + + manager.ensureCallbackListeners(); + assert.equal(typeof addedListeners.onTabUpdated, 'function'); + + addedListeners.onTabUpdated( + 88, + { status: 'complete' }, + { url: 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001' } + ); + + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(fetchCalls.length, 0); +}); + test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => { const source = fs.readFileSync('background/contribution-oauth.js', 'utf8'); const globalScope = {};