Merge pull request #261 from QLHazyCoder/fix/start-crash-on-auto-run-and-autosave-jitter

修复启动清理与设置自动保存抖动
This commit is contained in:
QLHazyCoder
2026-05-20 05:21:25 +08:00
committed by GitHub
7 changed files with 448 additions and 41 deletions
+43 -33
View File
@@ -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(`步骤 1browsingData 补扫 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() {
+17 -4
View File
@@ -290,7 +290,7 @@
}
async function closeConflictingTabsForSource(source, currentUrl, options = {}) {
const { excludeTabIds = [] } = options;
const { excludeTabIds = [], preserveActiveTab = false } = 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);
@@ -728,8 +732,11 @@
async function reuseOrCreateTab(source, url, options = {}) {
if (options.forceNew) {
await closeConflictingTabsForSource(source, url);
const tab = await createAutomationTab({ url, active: true }, options);
await closeConflictingTabsForSource(source, url, {
excludeTabIds: [tab.id],
preserveActiveTab: false,
});
if (options.inject) {
await waitForTabUpdateComplete(tab.id);
@@ -755,7 +762,10 @@
const alive = await isTabAlive(source);
if (alive) {
const tabId = await getTabId(source);
await closeConflictingTabsForSource(source, url, { excludeTabIds: [tabId] });
await closeConflictingTabsForSource(source, url, {
excludeTabIds: [tabId],
preserveActiveTab: false,
});
const currentTab = await chrome.tabs.get(tabId);
const sameUrl = currentTab.url === url;
const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl;
@@ -836,8 +846,11 @@
return tabId;
}
await closeConflictingTabsForSource(source, url);
const tab = await createAutomationTab({ url, active: true }, options);
await closeConflictingTabsForSource(source, url, {
excludeTabIds: [tab.id],
preserveActiveTab: false,
});
if (options.inject) {
await waitForTabUpdateComplete(tab.id);
+47 -4
View File
@@ -9950,15 +9950,31 @@ 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);
saveSettings({ silent: true, source: 'autosave' }).catch(() => { });
}, 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) {
@@ -9970,6 +9986,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',
@@ -9982,7 +10006,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) {
@@ -16579,6 +16608,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);
}
@@ -0,0 +1,123 @@
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' },
];
}
if (query?.domain === 'openai.com') {
return [
{
domain: '.openai.com',
path: '/',
name: 'shared',
storeId: 'store-a',
partitionKey: { topLevelSite: 'https://chatgpt.com' },
},
];
}
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',
},
{
url: 'https://openai.com/',
name: 'shared',
storeId: 'store-a',
partitionKey: { topLevelSite: 'https://chatgpt.com' },
},
]);
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);
});
@@ -383,6 +383,61 @@ test('tab runtime opens new automation tabs in the locked window', async () => {
assert.equal(created[0].windowId, 100);
});
test('tab runtime force-new opens replacement before removing the active stale source tab', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
const events = [];
let tabs = [
{ id: 1, active: true, windowId: 100, url: 'https://chatgpt.com/' },
];
const runtime = api.createTabRuntime({
LOG_PREFIX: '[test]',
addLog: async () => {},
chrome: {
tabs: {
create: async (payload) => {
events.push({ type: 'create', payload });
const tab = { id: 2, active: true, windowId: payload.windowId, url: payload.url };
tabs = tabs.map((item) => ({ ...item, active: false })).concat(tab);
return tab;
},
get: async (tabId) => tabs.find((tab) => tab.id === tabId),
query: async () => tabs,
remove: async (ids) => {
events.push({ type: 'remove', ids });
tabs = tabs.filter((tab) => !ids.includes(tab.id));
},
},
},
getSourceLabel: (sourceName) => sourceName || 'unknown',
getState: async () => ({
automationWindowId: 100,
sourceLastUrls: { 'signup-page': 'https://chatgpt.com/' },
tabRegistry: {},
}),
matchesSourceUrlFamily: (sourceName, candidateUrl) => (
sourceName === 'signup-page'
&& /chatgpt\.com|auth\.openai\.com/.test(String(candidateUrl || ''))
),
setState: async () => {},
throwIfStopped: () => {},
});
const tabId = await runtime.reuseOrCreateTab('signup-page', 'https://auth.openai.com/authorize', {
forceNew: true,
});
assert.equal(tabId, 2);
assert.deepEqual(events, [
{ type: 'create', payload: { url: 'https://auth.openai.com/authorize', active: true, windowId: 100 } },
{ type: 'remove', ids: [1] },
]);
assert.deepEqual(tabs, [
{ id: 2, active: true, windowId: 100, url: 'https://auth.openai.com/authorize' },
]);
});
test('tab runtime scopes tab queries to the locked automation window', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
+144
View File
@@ -0,0 +1,144 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
assert.notEqual(start, -1, `missing ${name}`);
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
let depth = 0;
for (let i = braceStart; i < source.length; i += 1) {
const ch = source[i];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(start, i + 1);
}
}
}
throw new Error(`unterminated ${name}`);
}
test('focused settings editor still autosaves without repainting the focused field', async () => {
const bundle = [
extractFunction('isEditableElementInSettingsCard'),
extractFunction('scheduleSettingsAutoSave'),
extractFunction('saveSettings'),
].join('\n');
const api = new Function(`
class MockElement {
constructor(tagName, type = '') {
this.tagName = tagName;
this.type = type;
this.isContentEditable = false;
}
}
const Element = MockElement;
const focusedInput = new MockElement('input', 'text');
const settingsCard = { contains: (element) => element === focusedInput };
const document = { activeElement: focusedInput };
let settingsAutoSaveTimer = null;
let scheduled = null;
let clearedTimer = null;
let settingsDirty = true;
let settingsSaveInFlight = false;
let settingsSaveRevision = 1;
const messages = [];
const syncedStates = [];
const dirtyMarks = [];
let applyCalls = 0;
function setTimeout(fn, delay) {
scheduled = { fn, delay };
return 71;
}
function clearTimeout(value) {
clearedTimer = value;
}
function collectSettingsPayload() {
return { vpsUrl: 'typed-value' };
}
function syncLatestState(state) {
syncedStates.push(state);
}
function markSettingsDirty(value = true) {
settingsDirty = value;
if (value) settingsSaveRevision += 1;
dirtyMarks.push(value);
}
function applySettingsState() {
applyCalls += 1;
}
function updateSaveButtonState() {}
function updatePanelModeUI() {}
function updateMailProviderUI() {}
function updateButtonStates() {}
const chrome = {
runtime: {
async sendMessage(message) {
messages.push(message);
return { state: { vpsUrl: message.payload.vpsUrl } };
},
},
};
${bundle}
return {
scheduleSettingsAutoSave,
async fireScheduled() {
await scheduled.fn();
},
getSnapshot() {
return {
applyCalls,
clearedTimer,
dirtyMarks,
messages,
scheduledDelay: scheduled?.delay,
syncedStates,
};
},
};
`)();
api.scheduleSettingsAutoSave();
let snapshot = api.getSnapshot();
assert.equal(snapshot.scheduledDelay, 1200);
assert.equal(snapshot.messages.length, 0);
await api.fireScheduled();
snapshot = api.getSnapshot();
assert.equal(snapshot.clearedTimer, 71);
assert.equal(snapshot.applyCalls, 0);
assert.deepEqual(snapshot.messages, [
{
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { vpsUrl: 'typed-value' },
},
]);
assert.deepEqual(snapshot.syncedStates, [{ vpsUrl: 'typed-value' }]);
assert.deepEqual(snapshot.dirtyMarks, [false]);
});
+19
View File
@@ -209,6 +209,25 @@ return {
assert.deepStrictEqual(snapshot.removedBatches, [[11, 12]]);
assert.strictEqual(snapshot.currentState.tabRegistry['signup-page'], null);
api.reset({
tabs: [
{ id: 21, active: true, url: 'https://chatgpt.com/' },
{ id: 22, active: false, url: 'https://auth.openai.com/authorize?client_id=test' },
],
state: {
sourceLastUrls: {
'signup-page': 'https://auth.openai.com/authorize?client_id=test',
},
tabRegistry: {},
},
});
await api.closeConflictingTabsForSource('signup-page', 'https://chatgpt.com/');
snapshot = api.snapshot();
assert.deepStrictEqual(snapshot.removedBatches, [[21, 22]]);
assert.deepStrictEqual(snapshot.currentTabs, []);
console.log('signup page tab cleanup tests passed');
})().catch((error) => {
console.error(error);