feat(config): 完善配置备份与导入校验
This commit is contained in:
+76
-24
@@ -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) {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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: '配置已导出',
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user