feat(config): 完善配置备份与导入校验

This commit is contained in:
Han Zheng
2026-07-23 17:30:01 +08:00
parent 58e0ef144d
commit 386ef79330
6 changed files with 144 additions and 93 deletions
+76 -24
View File
@@ -10,7 +10,41 @@
} = globalScope.TabHarborTabSessions || {};
const CONFIG_VERSION = 1;
const STORAGE_KEYS = ['themePreferences', 'quickShortcuts', 'savedTabSessions'];
const STORAGE_KEYS = [
'themePreferences',
'quickShortcuts',
'savedTabSessions',
'languagePreference',
'todos',
'sessionGroups',
'groupOrder',
'groupTabOrder',
'groupLabelOverrides',
'savedTabSessionOrder',
'savedTabSessionCollapsedState',
'chromeTabGroupsEnabled',
'chromeTabGroupsMeta',
'importedChromeSessionGroups',
'deferredTriggerPosition',
];
const STORAGE_DEFAULTS = {
themePreferences: null,
quickShortcuts: [],
savedTabSessions: [],
languagePreference: 'auto',
todos: [],
sessionGroups: { groups: [], assignments: {} },
groupOrder: { sessionOrder: [], pinnedOrder: [], pinEnabled: false },
groupTabOrder: {},
groupLabelOverrides: {},
savedTabSessionOrder: [],
savedTabSessionCollapsedState: {},
chromeTabGroupsEnabled: false,
chromeTabGroupsMeta: null,
importedChromeSessionGroups: { entries: [] },
deferredTriggerPosition: { top: null },
};
function isValidConfigObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -25,26 +59,58 @@
for (const key of STORAGE_KEYS) {
config[key] = key in data ? data[key] : null;
}
if (Array.isArray(config.quickShortcuts)) {
config.quickShortcuts = config.quickShortcuts.map(({ icon, iconKind, ...rest }) => rest);
}
return JSON.stringify(config, null, 2);
}
function getDefaultValue(key) {
const value = STORAGE_DEFAULTS[key];
if (Array.isArray(value)) return [];
if (value && typeof value === 'object') return structuredClone(value);
return value;
}
function isValidImportValue(key, value) {
if (value === null || value === undefined) return true;
if (key === 'quickShortcuts' || key === 'savedTabSessions' || key === 'todos' || key === 'savedTabSessionOrder') {
return Array.isArray(value);
}
if (key === 'languagePreference') return typeof value === 'string';
if (key === 'chromeTabGroupsEnabled') return typeof value === 'boolean';
return isValidConfigObject(value);
}
function validateImportData(parsed) {
if (!isValidConfigObject(parsed)) {
throw new Error('Invalid config: root must be an object');
}
const version = parsed.version == null ? CONFIG_VERSION : parsed.version;
if (version !== CONFIG_VERSION) {
throw new Error(`Invalid config: unsupported config version ${version}`);
}
const hasKey = STORAGE_KEYS.some(key => key in parsed);
if (!hasKey) {
throw new Error('Invalid config: missing recognized data keys');
}
if (parsed.quickShortcuts != null && !Array.isArray(parsed.quickShortcuts)) {
throw new Error('Invalid config: quickShortcuts must be an array');
for (const key of STORAGE_KEYS) {
if (key in parsed && !isValidImportValue(key, parsed[key])) {
const arrayKeys = ['quickShortcuts', 'savedTabSessions', 'todos', 'savedTabSessionOrder'];
const message = arrayKeys.includes(key)
? `${key} must be an array`
: `${key} has an invalid value`;
throw new Error(`Invalid config: ${message}`);
}
}
if (parsed.savedTabSessions != null && !Array.isArray(parsed.savedTabSessions)) {
throw new Error('Invalid config: savedTabSessions must be an array');
}
function normalizeImportValue(key, value) {
if (value === null || value === undefined) return getDefaultValue(key);
if (key === 'quickShortcuts') {
return apiNormalizeQuickShortcuts ? apiNormalizeQuickShortcuts(value) : value;
}
if (key === 'savedTabSessions') {
return apiNormalizeSavedTabSessions ? apiNormalizeSavedTabSessions(value) : value;
}
return value;
}
async function importConfig(jsonString) {
@@ -58,22 +124,8 @@
validateImportData(parsed);
const storagePayload = {};
if (isValidConfigObject(parsed.themePreferences)) {
storagePayload.themePreferences = parsed.themePreferences;
}
if (Array.isArray(parsed.quickShortcuts)) {
const stripped = parsed.quickShortcuts.map(({ icon, iconKind, ...rest }) => rest);
storagePayload.quickShortcuts = apiNormalizeQuickShortcuts
? apiNormalizeQuickShortcuts(stripped)
: stripped;
}
if (Array.isArray(parsed.savedTabSessions)) {
storagePayload.savedTabSessions = apiNormalizeSavedTabSessions
? apiNormalizeSavedTabSessions(parsed.savedTabSessions)
: parsed.savedTabSessions;
for (const key of STORAGE_KEYS) {
if (key in parsed) storagePayload[key] = normalizeImportValue(key, parsed[key]);
}
if (Object.keys(storagePayload).length === 0) {
+57 -54
View File
@@ -44,11 +44,23 @@ async function withMockStorage(initial, fn) {
}
}
test('exportConfig returns JSON with version, exportedAt, and all three keys', async () => {
test('exportConfig returns the complete versioned configuration with custom icons', async () => {
const initial = {
themePreferences: { mode: 'dark', paletteId: 'sage' },
quickShortcuts: [{ id: 's1', url: 'https://example.com', label: 'Example' }],
quickShortcuts: [{ id: 's1', url: 'https://example.com', label: 'Example', icon: '🔥', iconKind: 'glyph' }],
savedTabSessions: [],
languagePreference: 'zh-CN',
todos: [{ id: 'todo-1', title: 'Read' }],
sessionGroups: { groups: [], assignments: {} },
groupOrder: { sessionOrder: ['g1'], pinnedOrder: [], pinEnabled: true },
groupTabOrder: { g1: ['tab-1'] },
groupLabelOverrides: { g1: 'Work' },
savedTabSessionOrder: ['session-1'],
savedTabSessionCollapsedState: { 'session-1': true },
chromeTabGroupsEnabled: true,
chromeTabGroupsMeta: { entries: [] },
importedChromeSessionGroups: { entries: [] },
deferredTriggerPosition: { top: 120 },
};
await withMockStorage(initial, async () => {
@@ -60,24 +72,8 @@ test('exportConfig returns JSON with version, exportedAt, and all three keys', a
assert.deepEqual(parsed.themePreferences, initial.themePreferences);
assert.deepEqual(parsed.quickShortcuts, initial.quickShortcuts);
assert.deepEqual(parsed.savedTabSessions, initial.savedTabSessions);
});
});
test('exportConfig strips icon/iconKind from quickShortcuts', async () => {
const initial = {
quickShortcuts: [
{ id: 's1', url: 'https://example.com', label: 'Example', icon: 'data:image/png;base64,abc', iconKind: 'image' },
],
};
await withMockStorage(initial, async () => {
const json = await exportConfig();
const parsed = JSON.parse(json);
assert.equal(parsed.quickShortcuts.length, 1);
assert.ok(!('icon' in parsed.quickShortcuts[0]));
assert.ok(!('iconKind' in parsed.quickShortcuts[0]));
assert.equal(parsed.quickShortcuts[0].url, 'https://example.com');
for (const key of STORAGE_KEYS) assert.ok(key in parsed, `missing ${key}`);
assert.deepEqual(parsed, { ...parsed, ...initial });
});
});
@@ -87,16 +83,18 @@ test('exportConfig works with empty/missing data', async () => {
const parsed = JSON.parse(json);
assert.equal(parsed.version, CONFIG_VERSION);
assert.ok('themePreferences' in parsed);
assert.ok('quickShortcuts' in parsed);
assert.ok('savedTabSessions' in parsed);
for (const key of STORAGE_KEYS) {
assert.ok(key in parsed, `missing ${key}`);
assert.equal(parsed[key], null);
}
});
});
test('importConfig writes valid data to storage', async () => {
test('importConfig writes the complete configuration to storage', async () => {
const incoming = {
version: CONFIG_VERSION,
themePreferences: { mode: 'light', paletteId: 'mist' },
quickShortcuts: [{ id: 's2', url: 'https://test.dev', label: 'Test' }],
quickShortcuts: [{ id: 's2', url: 'https://test.dev', label: 'Test', icon: '🔥', iconKind: 'glyph' }],
savedTabSessions: [{
id: 'tab-session-123',
name: 'My tabs',
@@ -105,13 +103,25 @@ test('importConfig writes valid data to storage', async () => {
tabs: [{ url: 'https://example.com', title: 'Example' }],
groups: [],
}],
languagePreference: 'en',
todos: [],
sessionGroups: { groups: [], assignments: {} },
groupOrder: { sessionOrder: [], pinnedOrder: [], pinEnabled: false },
groupTabOrder: {},
groupLabelOverrides: {},
savedTabSessionOrder: [],
savedTabSessionCollapsedState: {},
chromeTabGroupsEnabled: false,
chromeTabGroupsMeta: null,
importedChromeSessionGroups: { entries: [] },
deferredTriggerPosition: { top: null },
};
const jsonString = JSON.stringify(incoming);
await withMockStorage({}, async (store) => {
const result = await importConfig(jsonString);
assert.deepEqual(result.importedKeys.sort(), ['quickShortcuts', 'savedTabSessions', 'themePreferences']);
assert.deepEqual(result.importedKeys.sort(), [...STORAGE_KEYS].sort());
assert.ok(store.themePreferences);
assert.ok(Array.isArray(store.quickShortcuts));
assert.ok(Array.isArray(store.savedTabSessions));
@@ -127,41 +137,34 @@ test('importConfig rejects invalid JSON', async () => {
});
});
test('importConfig strips icon/iconKind from quickShortcuts', async () => {
const incoming = {
quickShortcuts: [
{ id: 's1', url: 'https://example.com', label: 'Example', icon: 'data:image/png;base64,abc', iconKind: 'image' },
{ id: 's2', url: 'https://test.dev', label: 'Test', icon: '🔥', iconKind: 'glyph' },
],
};
await withMockStorage({}, async (store) => {
await importConfig(JSON.stringify(incoming));
assert.equal(store.quickShortcuts.length, 2);
for (const shortcut of store.quickShortcuts) {
assert.ok(!('icon' in shortcut));
assert.ok(!('iconKind' in shortcut));
}
assert.equal(store.quickShortcuts[0].url, 'https://example.com');
assert.equal(store.quickShortcuts[1].label, 'Test');
});
});
test('importConfig treats null values as absent (skips them)', async () => {
test('importConfig treats null values as explicit resets', async () => {
const incoming = {
themePreferences: null,
quickShortcuts: null,
savedTabSessions: [{ id: 's1', name: 'Test', savedAt: '2026-01-01T00:00:00.000Z', source: 'manual', tabs: [{ url: 'https://example.com', title: 'Example' }], groups: [] }],
savedTabSessions: null,
};
const initial = {
themePreferences: { mode: 'dark' },
quickShortcuts: [{ id: 'old', url: 'https://old.example' }],
savedTabSessions: [{ id: 'old' }],
};
await withMockStorage({}, async (store) => {
await withMockStorage(initial, async (store) => {
const result = await importConfig(JSON.stringify(incoming));
assert.deepEqual(result.importedKeys, ['savedTabSessions']);
assert.ok(!('themePreferences' in store));
assert.ok(!('quickShortcuts' in store));
assert.ok(Array.isArray(store.savedTabSessions));
assert.deepEqual(result.importedKeys.sort(), ['quickShortcuts', 'savedTabSessions', 'themePreferences']);
assert.equal(store.themePreferences, null);
assert.deepEqual(store.quickShortcuts, []);
assert.deepEqual(store.savedTabSessions, []);
});
});
test('importConfig rejects unsupported versions', async () => {
await withMockStorage({}, async () => {
await assert.rejects(
importConfig(JSON.stringify({ version: CONFIG_VERSION + 1, quickShortcuts: [] })),
/unsupported config version/,
);
});
});
+1 -5
View File
@@ -3055,15 +3055,11 @@ async function handleConfigImportInput(inputEl) {
try {
const text = await file.text();
const result = await configSync.importConfig(text);
await loadThemePreferences();
await renderQuickShortcuts();
if (document.body.classList.contains('showing-saved-tabs-page')) {
await globalThis.TabHarborSessionManager?.renderSavedTabsPage?.();
}
setThemeMenuOpen(false, { restoreFocus: true });
showToast(runtimeT
? runtimeT('toastConfigImported', { keys: result.importedKeys.length })
: `Imported ${result.importedKeys.length} setting${result.importedKeys.length !== 1 ? 's' : ''}`);
globalThis.location?.reload?.();
} catch (err) {
console.error('[tab-harbor] Import failed:', err);
showToast(err?.message || (runtimeT ? runtimeT('toastConfigImportFailed') : 'Import failed'));
+2 -2
View File
@@ -177,7 +177,7 @@
savedSessionNavDisplayModeName: 'Group name',
sleepControlLabel: 'Manual sleep control',
closeDuplicateNewTabsLabel: 'Auto-close duplicate new tabs',
settingsExportImport: 'Backup',
settingsExportImport: 'Configuration backup',
settingsExport: 'Export',
settingsImport: 'Import',
toastConfigExported: 'Config exported',
@@ -357,7 +357,7 @@
savedSessionNavDisplayModeName: '分组名称',
sleepControlLabel: '手动休眠控制',
closeDuplicateNewTabsLabel: '自动关闭重复新标签页',
settingsExportImport: '备份',
settingsExportImport: '配置备份',
settingsExport: '导出',
settingsImport: '导入',
toastConfigExported: '配置已导出',
+7 -7
View File
@@ -552,7 +552,7 @@ test('theme menu styles and custom background layer are defined', () => {
assert.match(themeJs, /'--workspace-accent-soft':/);
assert.match(themeJs, /'--workspace-accent-border':/);
assert.match(themeJs, /'--workspace-accent-contrast':/);
assert.match(css, /\.mission-card\s*\{[\s\S]*background:\s*color-mix\(in srgb, var\(--card-bg\) calc\(var\(--custom-surface-opacity\) \+ 68%\), transparent\);/);
assert.match(css, /\.mission-card\s*\{[\s\S]*background:\s*color-mix\(\s*in\s+srgb,\s*var\(--card-bg\)\s+calc\(var\(--custom-surface-opacity\)\s*\+\s*68%\),\s*transparent\s*\);/);
assert.match(css, /\.section-count\s*\{[\s\S]*color:\s*var\(--workspace-chip-text\);/);
assert.match(css, /\.group-nav-button\s*\{[\s\S]*width:\s*40px;[\s\S]*height:\s*40px;/);
assert.match(css, /\.group-nav-button::after\s*\{[\s\S]*background:\s*var\(--tooltip-surface\);[\s\S]*color:\s*var\(--tooltip-text\);[\s\S]*border:\s*1px solid var\(--tooltip-border\);/);
@@ -561,13 +561,13 @@ test('theme menu styles and custom background layer are defined', () => {
assert.match(css, /\.tab-cleanup-btn\s*\{[\s\S]*background:\s*var\(--banner-action-bg\);[\s\S]*color:\s*var\(--banner-action-text\);/);
assert.match(css, /\.tab-cleanup-btn:hover\s*\{[\s\S]*background:\s*var\(--banner-action-bg-hover\);/);
assert.match(css, /\.duplicate-count-badge\s*\{[\s\S]*color:\s*var\(--workspace-chip-text\);[\s\S]*background:\s*var\(--workspace-chip-bg-strong\);[\s\S]*border:\s*1px solid var\(--workspace-chip-border\);/);
assert.match(css, /\.action-btn\.close-tabs\s*\{[\s\S]*border-color:\s*var\(--workspace-chip-border\);[\s\S]*color:\s*var\(--workspace-chip-text\);[\s\S]*background:\s*color-mix\(in srgb, var\(--workspace-chip-bg\) 92%, var\(--card-bg\) 8%\);[\s\S]*border-radius:\s*8px;[\s\S]*min-height:\s*28px;/);
assert.match(css, /\.action-btn\.close-tabs\s*\{[\s\S]*border-color:\s*var\(--workspace-chip-border\);[\s\S]*color:\s*var\(--workspace-chip-text\);[\s\S]*background:\s*color-mix\(\s*in\s+srgb,\s*var\(--workspace-chip-bg\)\s+92%,\s*var\(--card-bg\)\s+8%\s*\);[\s\S]*border-radius:\s*8px;[\s\S]*min-height:\s*28px;/);
assert.match(css, /\.action-btn\.close-tabs:hover\s*\{[\s\S]*background:\s*var\(--workspace-chip-bg-strong\);[\s\S]*border-color:\s*var\(--workspace-accent-border\);/);
assert.match(css, /\.deferred-shell\s*\{[\s\S]*background:\s*color-mix\(in srgb, var\(--card-bg\) var\(--panel-card-opacity\), transparent\);/);
assert.match(css, /--tooltip-surface:\s*color-mix\(in srgb, var\(--workspace-accent-soft\) 32%, var\(--card-bg\) 68%\);/);
assert.match(css, /\.deferred-shell\s*\{[\s\S]*background:\s*color-mix\(\s*in\s+srgb,\s*var\(--card-bg\)\s+var\(--panel-card-opacity\),\s*transparent\s*\);/);
assert.match(css, /--tooltip-surface:\s*color-mix\(\s*in\s+srgb,\s*var\(--workspace-accent-soft\)\s+32%,\s*var\(--card-bg\)\s+68%\s*\);/);
assert.match(css, /\.drawer-title-btn\.is-active,\s*\.drawer-title-btn\[aria-selected="true"\]\s*\{[\s\S]*text-decoration-color:\s*var\(--drawer-tab-underline-active\);/);
assert.match(css, /\.archive-clear-btn\s*\{[\s\S]*color:\s*var\(--workspace-chip-text\);/);
assert.match(css, /\.todo-detail-card\s*\{[\s\S]*background:\s*color-mix\(in srgb, var\(--card-bg\) 96%, var\(--paper\) 4%\);/);
assert.match(css, /\.todo-detail-card\s*\{[\s\S]*background:\s*color-mix\(\s*in\s+srgb,\s*var\(--card-bg\)\s+96%,\s*var\(--paper\)\s+4%\s*\);/);
assert.match(appJs, /compressImageFileForStorage/);
assert.doesNotMatch(appJs, /readFileAsDataUrl/);
assert.match(html, /<script src="background-image\.js"><\/script>/);
@@ -590,7 +590,7 @@ test('quick tabs area renders shortcut cards and add button hooks', () => {
assert.match(css, /\.quick-shortcut-edit\s*\{[\s\S]*left:\s*0;[\s\S]*width:\s*18px;[\s\S]*height:\s*18px;/);
assert.match(css, /\.quick-shortcut-edit\s*\{[\s\S]*transform:\s*translateY\(2px\) scale\(0\.92\);/);
assert.match(css, /\.quick-shortcut-card:hover \.quick-shortcut-edit,[\s\S]*transform:\s*translateY\(0\) scale\(1\);/);
assert.match(css, /\.quick-shortcut-edit:hover,[\s\S]*border-color:\s*color-mix\(in srgb, var\(--workspace-accent-border\) 38%, transparent\);/);
assert.match(css, /\.quick-shortcut-edit:hover,[\s\S]*border-color:\s*color-mix\(\s*in\s+srgb,\s*var\(--workspace-accent-border\)\s+38%,\s*transparent\s*\);/);
assert.match(css, /\.shortcut-editor\s*\{/);
assert.match(css, /\.shortcut-editor\s*\{[\s\S]*inset:\s*auto 88px 24px auto;/);
assert.match(css, /\.shortcut-editor-preview\s*\{/);
@@ -1050,7 +1050,7 @@ test('dynamic animation styles are generated by JavaScript instead of hardcoded
assert.match(appJs, /injectDynamicAnimationStyles\(\);/);
assert.match(appJs, /primeEntryAnimations\(\);/);
assert.match(appJs, /document\.addEventListener\('pointerdown', disableEntryAnimations, \{ capture: true, passive: true \}\)/);
assert.match(css, /body\.entry-animations-enabled header \{ animation: fadeUp 0\.5s ease both; \}/);
assert.match(css, /body\.entry-animations-enabled header\s*\{[\s\S]*animation:\s*fadeUp\s+0\.5s\s+ease\s+both;[\s\S]*\}/);
});
test('dashboard auto-refreshes when tabs change via background message', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Tab Harbor",
"version": "1.1.1",
"version": "1.1.2",
"description": "A new tab dashboard for organizing open tabs, quick links, todos, and saved tab sessions in one calm workspace.",
"permissions": ["tabs", "storage", "search", "clipboardRead", "tabGroups", "favicon"],
"chrome_url_overrides": { "newtab": "extension/index.html" },