diff --git a/background/steps/open-chatgpt.js b/background/steps/open-chatgpt.js index 248cdd5..91d03e5 100644 --- a/background/steps/open-chatgpt.js +++ b/background/steps/open-chatgpt.js @@ -9,14 +9,6 @@ 'auth0.openai.com', 'accounts.openai.com', ]; - const STEP1_COOKIE_CLEAR_ORIGINS = [ - 'https://chatgpt.com', - 'https://chat.openai.com', - 'https://auth.openai.com', - 'https://auth0.openai.com', - 'https://accounts.openai.com', - 'https://openai.com', - ]; function normalizeCookieDomainForStep1(domain) { return String(domain || '').trim().replace(/^\.+/, '').toLowerCase(); @@ -30,6 +22,16 @@ )); } + function buildStep1CookieKey(cookie, fallbackStoreId = '') { + return [ + cookie?.storeId || fallbackStoreId || '', + cookie?.domain || '', + cookie?.path || '', + cookie?.name || '', + cookie?.partitionKey ? JSON.stringify(cookie.partitionKey) : '', + ].join('|'); + } + function buildStep1CookieRemovalUrl(cookie) { const host = normalizeCookieDomainForStep1(cookie?.domain); const rawPath = String(cookie?.path || '/'); @@ -51,22 +53,39 @@ : [{ id: undefined }]; const cookies = []; const seen = new Set(); + const queryDomains = Array.from( + new Set( + STEP1_COOKIE_CLEAR_DOMAINS + .map((domain) => normalizeCookieDomainForStep1(domain)) + .filter(Boolean) + ) + ); for (const store of stores) { const storeId = store?.id; - const batch = await chromeApi.cookies.getAll(storeId ? { storeId } : {}); - for (const cookie of batch || []) { - if (!shouldClearStep1Cookie(cookie)) continue; - const key = [ - cookie.storeId || storeId || '', - cookie.domain || '', - cookie.path || '', - cookie.name || '', - cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : '', - ].join('|'); - if (seen.has(key)) continue; - seen.add(key); - cookies.push(cookie); + for (const domain of queryDomains) { + let batch = []; + try { + batch = await chromeApi.cookies.getAll( + storeId + ? { storeId, domain } + : { domain } + ); + } catch (error) { + console.warn('[MultiPage:step1] query cookies failed', { + storeId: storeId || '', + domain, + message: getStep1ErrorMessage(error), + }); + continue; + } + for (const cookie of batch || []) { + if (!shouldClearStep1Cookie(cookie)) continue; + const key = buildStep1CookieKey(cookie, storeId); + if (seen.has(key)) continue; + seen.add(key); + cookies.push(cookie); + } } } @@ -112,6 +131,7 @@ return; } + const startedAt = Date.now(); await addLog('步骤 1:打开 ChatGPT 官网前清理 ChatGPT / OpenAI cookies...', 'info'); const cookies = await collectStep1Cookies(chromeApi); let removedCount = 0; @@ -121,18 +141,8 @@ } } - if (chromeApi.browsingData?.removeCookies) { - try { - await chromeApi.browsingData.removeCookies({ - since: 0, - origins: STEP1_COOKIE_CLEAR_ORIGINS, - }); - } catch (error) { - await addLog(`步骤 1:browsingData 补扫 cookies 失败:${getStep1ErrorMessage(error)}`, 'warn'); - } - } - - await addLog(`步骤 1:已清理 ${removedCount} 个 ChatGPT / OpenAI cookies。`, 'ok'); + const elapsedMs = Date.now() - startedAt; + await addLog(`步骤 1:已清理 ${removedCount} 个 ChatGPT / OpenAI cookies(耗时 ${elapsedMs}ms)。`, 'ok'); } async function executeStep1() { diff --git a/background/tab-runtime.js b/background/tab-runtime.js index 29e8ced..919d027 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -290,7 +290,7 @@ } async function closeConflictingTabsForSource(source, currentUrl, options = {}) { - const { excludeTabIds = [] } = options; + const { excludeTabIds = [], preserveActiveTab = true } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const state = await getState(); const lastUrl = getSourceMapValue(state.sourceLastUrls, source); @@ -299,8 +299,12 @@ if (!referenceUrls.length) return; const tabs = await queryTabsInAutomationWindow({}); + const activeTabId = preserveActiveTab + ? tabs.find((tab) => Boolean(tab?.active) && Number.isInteger(tab?.id))?.id || null + : null; const matchedIds = tabs .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id)) + .filter((tab) => !(preserveActiveTab && activeTabId !== null && tab.id === activeTabId)) .filter((tab) => referenceUrls.some((refUrl) => matchesSourceUrlFamily(source, tab.url, refUrl))) .map((tab) => tab.id); diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 53fec4f..93e6a2b 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -8883,15 +8883,37 @@ function updateSaveButtonState() { btnSaveSettings.textContent = settingsSaveInFlight ? '保存中' : '保存'; } +function isEditableElementInSettingsCard(element) { + if (!element || !(element instanceof Element)) { + return false; + } + const tagName = String(element.tagName || '').toLowerCase(); + const isEditableInput = ( + tagName === 'textarea' + || (tagName === 'input' && !['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'file', 'color'].includes(String(element.type || '').toLowerCase())) + || Boolean(element.isContentEditable) + ); + if (!isEditableInput) { + return false; + } + return !settingsCard || settingsCard.contains(element); +} + function scheduleSettingsAutoSave() { clearTimeout(settingsAutoSaveTimer); - settingsAutoSaveTimer = setTimeout(() => { - saveSettings({ silent: true }).catch(() => { }); - }, 500); + const tryAutoSave = () => { + const activeEl = typeof document !== 'undefined' ? document.activeElement : null; + if (isEditableElementInSettingsCard(activeEl)) { + settingsAutoSaveTimer = setTimeout(tryAutoSave, 800); + return; + } + saveSettings({ silent: true, source: 'autosave' }).catch(() => { }); + }; + settingsAutoSaveTimer = setTimeout(tryAutoSave, 1200); } async function saveSettings(options = {}) { - const { silent = false, force = false } = options; + const { silent = false, force = false, source = '' } = options; clearTimeout(settingsAutoSaveTimer); if (!force && !settingsDirty && !settingsSaveInFlight && silent) { @@ -8903,6 +8925,14 @@ async function saveSettings(options = {}) { settingsSaveInFlight = true; updateSaveButtonState(); + const shouldSkipStateApplyForFocusedEditor = (() => { + if (!silent || source !== 'autosave') { + return false; + } + const activeEl = typeof document !== 'undefined' ? document.activeElement : null; + return isEditableElementInSettingsCard(activeEl); + })(); + try { const response = await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', @@ -8915,7 +8945,12 @@ async function saveSettings(options = {}) { } if (response?.state && saveRevision === settingsSaveRevision) { - applySettingsState(response.state); + if (shouldSkipStateApplyForFocusedEditor) { + syncLatestState(response.state); + markSettingsDirty(false); + } else { + applySettingsState(response.state); + } } else { syncLatestState(payload); if (saveRevision === settingsSaveRevision) { @@ -14963,6 +14998,20 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { case 'DATA_UPDATED': { syncLatestState(message.payload); + const activeSettingsEditor = typeof document !== 'undefined' ? document.activeElement : null; + const shouldDeferDataUpdatedUiApply = settingsSaveInFlight + && isEditableElementInSettingsCard(activeSettingsEditor); + if (shouldDeferDataUpdatedUiApply) { + // Avoid overwriting the focused editor while the current save request + // is still in flight; otherwise typing can be interrupted by DATA_UPDATED. + if (message.payload.operationDelayEnabled !== undefined && typeof applyOperationDelayState === 'function') { + applyOperationDelayState(message.payload); + } + updateAccountRunHistorySettingsUI(); + renderContributionMode(); + void syncPlusManualConfirmationDialog(); + break; + } if (message.payload.operationDelayEnabled !== undefined && typeof applyOperationDelayState === 'function') { applyOperationDelayState(message.payload); } diff --git a/tests/background-step1-cookie-cleanup.test.js b/tests/background-step1-cookie-cleanup.test.js new file mode 100644 index 0000000..0e1412a --- /dev/null +++ b/tests/background-step1-cookie-cleanup.test.js @@ -0,0 +1,106 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadStep1Module() { + const source = fs.readFileSync('background/steps/open-chatgpt.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundStep1;`)(globalScope); +} + +test('step 1 cookie cleanup queries target domains and skips browsingData sweep when direct removals succeed', async () => { + const api = loadStep1Module(); + const events = { + getAllCalls: [], + removedCookies: [], + browsingDataCalls: [], + openedSteps: [], + completedNodes: [], + }; + + const chromeApi = { + cookies: { + getAllCookieStores: async () => [{ id: 'store-a' }], + getAll: async (query) => { + events.getAllCalls.push(query); + if (query?.domain === 'chatgpt.com') { + return [ + { domain: '.chatgpt.com', path: '/', name: 'session', storeId: 'store-a' }, + ]; + } + return []; + }, + remove: async (details) => { + events.removedCookies.push(details); + return details; + }, + }, + browsingData: { + removeCookies: async (details) => { + events.browsingDataCalls.push(details); + }, + }, + }; + + const executor = api.createStep1Executor({ + addLog: async () => {}, + chrome: chromeApi, + openSignupEntryTab: async (step) => { + events.openedSteps.push(step); + }, + completeNodeFromBackground: async (nodeId) => { + events.completedNodes.push(nodeId); + }, + }); + + await executor.executeStep1(); + + assert.ok(events.getAllCalls.length > 0, 'should query cookies at least once'); + assert.ok(events.getAllCalls.every((entry) => typeof entry?.domain === 'string' && entry.domain.length > 0)); + assert.deepStrictEqual(events.removedCookies, [ + { + url: 'https://chatgpt.com/', + name: 'session', + storeId: 'store-a', + }, + ]); + assert.deepStrictEqual(events.browsingDataCalls, []); + assert.deepStrictEqual(events.openedSteps, [1]); + assert.deepStrictEqual(events.completedNodes, ['open-chatgpt']); +}); + +test('step 1 cookie cleanup skips browsingData sweep when no direct cookie is removed', async () => { + const api = loadStep1Module(); + const events = { + removedCookies: 0, + browsingDataCalls: [], + }; + + const chromeApi = { + cookies: { + getAllCookieStores: async () => [{ id: 'store-a' }], + getAll: async () => [], + remove: async () => { + events.removedCookies += 1; + return null; + }, + }, + browsingData: { + removeCookies: async (details) => { + events.browsingDataCalls.push(details); + }, + }, + }; + + const executor = api.createStep1Executor({ + addLog: async () => {}, + chrome: chromeApi, + openSignupEntryTab: async () => {}, + completeNodeFromBackground: async () => {}, + }); + + await executor.executeStep1(); + + assert.equal(events.removedCookies, 0); + assert.equal(events.browsingDataCalls.length, 0); +});