diff --git a/README.md b/README.md index e0a56a22..3bacef75 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,13 @@ What started as an experiment to play with the Chrome browser APIs and explore w - Distraction-free, minimal design aesthetic with multiple themes. - A list of your open tabs, recently closed tabs, and top sites. - Search tabs, bookmarks, history, and top sites in one place. -- Simple bookmarks bar. - Links to frequently used destinations in your browser. +- Simple bookmarks bar. +- Customisable UI. +- Optional automatic or manual settings sync between browsers. ### Design goals - | Issue | Why / How | | --- | --- | | Access | Still have access to common things like the bookmarks bar etc. | diff --git a/src/settings.ts b/src/settings.ts index 63501ce7..808599b1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,7 +1,12 @@ import { append, clone, collect, h } from 'stage1'; import { compile } from 'stage1/macro' with { type: 'macro' }; import { reconcile } from 'stage1/reconcile/non-keyed'; -import type { SectionOrderItem, ThemesData } from './types'; +import type { + SectionOrderItem, + SyncStorageData, + ThemesData, + UserStorageData, +} from './types'; import { DEFAULT_SECTION_ORDER, storage } from './utils'; // TODO: Show errors in the UI. @@ -10,6 +15,7 @@ import { DEFAULT_SECTION_ORDER, storage } from './utils'; interface SettingsState { order: [SectionOrderItem[], SectionOrderItem[]]; + pushSyncData?(forceUpdate?: boolean): Promise; } type ItemIndex = [listIndex: 0 | 1, itemIndex: number]; @@ -28,6 +34,19 @@ const themesData = fetch('themes.json').then( (response) => response.json() as Promise, ); +const supportsSync = async (): Promise => { + try { + await chrome.storage.sync.set({ _: 1 }); + await chrome.storage.sync.remove('_'); + if (chrome.runtime.lastError) { + return false; + } + return true; + } catch { + return false; + } +}; + type SectionComponent = HTMLLIElement; interface SectionRefs { @@ -102,17 +121,23 @@ const SectionItem = ( }; interface Refs { - feedback: HTMLDivElement; + feedback: Text; theme: HTMLSelectElement; b: HTMLInputElement; se: HTMLUListElement; sd: HTMLUListElement; reset: HTMLButtonElement; + + feedback2: Text; + sync: HTMLInputElement; + pull: HTMLButtonElement; + push: HTMLButtonElement; + clear: HTMLButtonElement; } const meta = compile(`
-
+
@feedback
@@ -151,6 +176,29 @@ const meta = compile(`
+ +
+ +

Experimental

+ +

Sync Settings

+ +
+ @feedback2 +
+ +
+ + Sync on profile startup (requires sign-in) +
+ +
+ + + +
`); const view = h(meta.html); @@ -192,8 +240,10 @@ const Settings = () => { }); if (themeName === DEFAULT_THEME) { - void chrome.storage.local.remove('tn'); + await chrome.storage.local.remove('tn'); } + + void state.pushSyncData?.(); }; const updateOrder = (order: SettingsState['order'], skipSave?: boolean) => { @@ -214,6 +264,8 @@ const Settings = () => { o: order[0], }); } + + void state.pushSyncData?.(); } }; @@ -234,15 +286,18 @@ const Settings = () => { refs.theme.onchange = () => updateTheme(refs.theme.value); - refs.b.onchange = () => { + refs.b.onchange = async () => { + // eslint-disable-next-line unicorn/prefer-ternary if (refs.b.checked) { // When value is same as default, we don't need to store it - void chrome.storage.local.remove('b'); + await chrome.storage.local.remove('b'); } else { - void chrome.storage.local.set({ + await chrome.storage.local.set({ b: true, }); } + + void state.pushSyncData?.(); }; // eslint-disable-next-line no-multi-assign @@ -266,10 +321,84 @@ const Settings = () => { (item) => !orderEnabled.includes(item), ); - void updateTheme(themeName); + refs.theme.value = themeName; refs.b.checked = !storage.b; updateOrder([orderEnabled, orderDisabled], true); + /* ********************************** */ + // Experimental sync settings feature // + /* ********************************** */ + + refs.sync.checked = !!storage.s; + + const updateSync = (syncData: SyncStorageData) => { + if (syncData.ts) { + refs.feedback2.nodeValue = `Sync data found (last updated: ${new Date( + syncData.ts, + ).toLocaleString()})`; + refs.pull.disabled = false; + refs.clear.disabled = false; + } else { + refs.feedback2.nodeValue = 'No sync data found'; + refs.pull.disabled = true; + refs.clear.disabled = true; + } + + refs.push.disabled = false; + refs.sync.disabled = false; + + refs.sync.onchange = () => { + if (refs.sync.checked) { + void chrome.storage.local.set({ + s: true, + }); + // @ts-expect-error - doesn't need event argument + refs.pull.onclick?.(); + } else { + void chrome.storage.local.remove('s'); + } + }; + + refs.pull.onclick = () => { + if (syncData.data) { + void chrome.storage.local.set(syncData.data); + void updateTheme(syncData.data.tn ?? DEFAULT_THEME); + updateOrder([syncData.data.o ?? [...DEFAULT_SECTION_ORDER], []], true); + } + }; + + state.pushSyncData = async (forceUpdate?: boolean) => { + const { t, s, ...rest } = + await chrome.storage.local.get(); + + if (forceUpdate || s) { + const newSyncData: SyncStorageData = { + data: rest, + ts: Date.now(), + }; + void chrome.storage.sync.set(newSyncData); + updateSync(newSyncData); + } + }; + + refs.push.onclick = () => state.pushSyncData!(true); + + refs.clear.onclick = () => { + void chrome.storage.sync.clear(); + updateSync({}); + }; + }; + + void supportsSync().then((canSync) => { + if (canSync) { + void chrome.storage.sync.get().then(updateSync); + // TODO: Listen for sync data changes? + // chrome.storage.sync.onChanged.addListener((changes) => {}); + } else { + refs.feedback2.nodeValue = 'Not signed in or sync not supported'; + } + }); + return root; }; diff --git a/src/sw.ts b/src/sw.ts index 1504971f..c3f4ed00 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,13 +1,13 @@ /// -import type { ThemesData, UserStorageData } from './types'; +import type { SyncStorageData, ThemesData, UserStorageData } from './types'; // On install or subsequent update, preload the user's chosen theme into storage // eslint-disable-next-line @typescript-eslint/no-misused-promises chrome.runtime.onInstalled.addListener(async () => { const [themes, settings] = await Promise.all([ fetch('themes.json').then((res) => res.json() as Promise), - chrome.storage.local.get(), + chrome.storage.local.get('tn'), ]); // TODO: Remove once most users have updated. @@ -30,3 +30,34 @@ chrome.runtime.onInstalled.addListener(async () => { // }); // } }); + +/* ********************************** */ +// Experimental sync settings feature // +/* ********************************** */ + +// On profile startup, pull remote user settings; local <- sync +chrome.runtime.onStartup.addListener(() => { + void chrome.storage.local + .get(['s', 'tn']) + .then((settings) => { + // Only when sync is enabled + if (!settings.s) return; + + void chrome.storage.sync.get().then((remote) => { + if (remote.data) { + if (remote.data.tn === settings.tn) { + void chrome.storage.local.set(remote.data); + } else { + void fetch('themes.json') + .then((res) => res.json() as Promise) + .then((themes) => { + void chrome.storage.local.set({ + t: themes[settings.tn ?? 'auto'], + ...remote.data, + }); + }); + } + } + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 777bc4bf..b1c6ad7b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,4 +22,13 @@ export interface UserStorageData { b?: boolean; /** Sections order user preference. */ o?: SectionOrderItem[]; + /** Settings sync enabled user preference. */ + s?: boolean; +} + +export interface SyncStorageData { + /** User settings data. */ + data?: Omit; + /** Timestamp of the last sync.set operation. */ + ts?: number; } diff --git a/test/e2e/settings.spec.ts b/test/e2e/settings.spec.ts index ad4c5481..94249642 100644 --- a/test/e2e/settings.spec.ts +++ b/test/e2e/settings.spec.ts @@ -4,17 +4,18 @@ import { expect, test } from './fixtures'; test('settings page', async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/settings.html`); - // FIXME: Better assertions + // FIXME: Better assertions. await expect(page).toHaveTitle('New Tab'); await expect(page).toHaveURL(`chrome-extension://${extensionId}/settings.html`); // didn't redirect const labels = await page.locator('label').all(); - expect(labels).toHaveLength(4); await expect(labels[0]).toHaveText('Theme'); await expect(labels[1]).toHaveText('Show bookmarks bar'); await expect(labels[2]).toHaveText('Sections'); await expect(labels[3]).toHaveText('Reset'); + await expect(labels[4]).toHaveText('Automatically sync settings'); + expect(labels).toHaveLength(5); }); test.skip('matches screenshot', async ({ page, extensionId }) => { @@ -32,4 +33,4 @@ test('has no console calls or unhandled errors', async ({ page, extensionId }) = expect(consoleMessages).toHaveLength(0); }); -// TODO: Test it makes no external requests (other than fetch themes) +// TODO: Test it makes no external requests (other than fetch themes). diff --git a/test/setup.ts b/test/setup.ts index cb50b1a5..73c8144c 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -23,8 +23,15 @@ function setupMocks(): void { history: { search: noopAsyncArr, }, - // @ts-expect-error - partial mock runtime: { + // @ts-expect-error - partial mock + onInstalled: { + addListener: noop, + }, + // @ts-expect-error - partial mock + onStartup: { + addListener: noop, + }, openOptionsPage: noopAsync, }, // @ts-expect-error - partial mock @@ -38,6 +45,13 @@ function setupMocks(): void { remove: noopAsync, set: noopAsync, }, + // @ts-expect-error - partial mock + sync: { + clear: noopAsync, + get: noopAsyncObj, + remove: noopAsync, + set: noopAsync, + }, }, tabs: { // @ts-expect-error - partial mock diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 6621dbe6..c1058cd6 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -19,8 +19,8 @@ describe('dist files', () => { ['newtab.js', 'text/javascript;charset=utf-8', 4000, 6000], ['settings.css', 'text/css;charset=utf-8', 1000, 1500], ['settings.html', 'text/html;charset=utf-8', 150, 200], - ['settings.js', 'text/javascript;charset=utf-8', 4000, 6000], - ['sw.js', 'text/javascript;charset=utf-8', 150, 300], + ['settings.js', 'text/javascript;charset=utf-8', 6000, 8000], + ['sw.js', 'text/javascript;charset=utf-8', 400, 600], ['themes.json', 'application/json;charset=utf-8'], ]; diff --git a/test/unit/newtab.test.ts b/test/unit/newtab.test.ts index 610574f2..dcddf331 100644 --- a/test/unit/newtab.test.ts +++ b/test/unit/newtab.test.ts @@ -6,7 +6,7 @@ import { reset } from '../setup'; // Completely reset DOM and global state between tests afterEach(reset); -const MODULE_PATH = import.meta.resolveSync('../../dist/newtab.js'); +const MODULE_PATH = Bun.resolveSync('./dist/newtab.js', '.'); async function load() { // Workaround for hack in src/BookmarkBar.ts that waits for styles to be loaded @@ -26,8 +26,8 @@ test('renders entire newtab app', async () => { expect(document.body.querySelector('#m')).toBeTruthy(); expect(document.body.querySelector('#d')).toBeTruthy(); - // TODO: More/better assertions - // TODO: Check all section headings exist; a h2 with text 'Open Tabs' x5 + // TODO: More and better assertions. + // TODO: Check all section headings exist; a h2 with text 'Open Tabs' x5. }); test('does not call any console methods', async () => { diff --git a/test/unit/settings.test.ts b/test/unit/settings.test.ts index f6967e87..7a8a9754 100644 --- a/test/unit/settings.test.ts +++ b/test/unit/settings.test.ts @@ -6,7 +6,7 @@ import { reset } from '../setup'; // Completely reset DOM and global state between tests afterEach(reset); -const MODULE_PATH = import.meta.resolveSync('../../dist/settings.js'); +const MODULE_PATH = Bun.resolveSync('./dist/settings.js', '.'); const themes = Bun.file('dist/themes.json'); async function load() { @@ -32,7 +32,7 @@ test('renders entire settings app', async () => { await load(); expect(document.body.innerHTML.length).toBeGreaterThan(600); - // TODO: More/better assertions + // TODO: More and better assertions. }); test('does not call any console methods', async () => { diff --git a/test/unit/sw.test.ts b/test/unit/sw.test.ts index f7a0bb59..93c54c07 100644 --- a/test/unit/sw.test.ts +++ b/test/unit/sw.test.ts @@ -1,2 +1,81 @@ -// TODO -$console.warn('Not implemented'); +import { afterEach, expect, test } from 'bun:test'; +import { spyOn } from 'bun:test'; +import { describe } from 'bun:test'; +import { performanceSpy } from '@maxmilton/test-utils/spy'; +import { reset } from '../setup'; + +// Completely reset DOM and global state between tests +afterEach(reset); + +const MODULE_PATH = Bun.resolveSync('./dist/sw.js', '.'); + +async function load(noMocks?: boolean) { + if (!noMocks) { + const originalFetch = global.fetch; + + // @ts-expect-error - monkey patching fetch for testing + global.fetch = (input, init) => { + if (input === 'themes.json') { + return Promise.resolve({ + json: () => Promise.resolve({}), + }); + } + return originalFetch(input, init); + }; + + chrome.runtime.onInstalled.addListener = (callback) => { + callback({ reason: 'install' as chrome.runtime.OnInstalledReason.INSTALL }); + }; + chrome.runtime.onStartup.addListener = (callback) => { + callback(); + }; + } + + Loader.registry.delete(MODULE_PATH); + await import(MODULE_PATH); + await happyDOM.waitUntilComplete(); +} + +test('does not call any console methods', async () => { + expect.assertions(1); + await load(); + expect(happyDOM.virtualConsolePrinter.read()).toBeArrayOfSize(0); +}); + +test('does not call any performance methods', async () => { + expect.hasAssertions(); // variable number of assertions + const check = performanceSpy(); + await load(); + check(); +}); + +test('does not call fetch() except themes.json', async () => { + expect.assertions(1); + const spy = spyOn(global, 'fetch'); + await load(); + expect(spy).not.toHaveBeenCalled(); +}); + +describe('onInstalled', () => { + test('has listener', async () => { + expect.assertions(1); + const spy = spyOn(chrome.runtime.onInstalled, 'addListener'); + await load(true); + expect(spy).toHaveBeenCalledTimes(1); + }); + + // TODO: Test with various settings. + // TODO: More and better assertions. +}); + +describe('onStartup', () => { + test('has listener', async () => { + expect.assertions(1); + const spy = spyOn(chrome.runtime.onStartup, 'addListener'); + await load(true); + expect(spy).toHaveBeenCalledTimes(1); + }); + + // TODO: Test with various settings, especially sync enabled/disabled. + // TODO: More and better assertions. +});