From c3d6ca2b6d60503b347eb87c539ada7f61018b23 Mon Sep 17 00:00:00 2001 From: juansilvadesign Date: Thu, 9 Jul 2026 11:11:24 -0300 Subject: [PATCH] =?UTF-8?q?feat(storage):=20configura=C3=A7=C3=B5es=20acom?= =?UTF-8?q?panham=20o=20perfil=20entre=20dispositivos=20(storage.sync)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As chaves de preferência — settings do motor, lastMode, memória por canal e tema — passam a viver em chrome.storage.sync, atrás de uma fachada em common.js (settingsArea/getSettings/setSettings). Migração local→sync única e idempotente (planSyncMigration, pura e testada; executada no boot do worker e na abertura do popup), com leitura de reserva no storage.local até ela rodar — usuário existente nunca vê um flash de padrões. Nenhuma chave é renomeada. Ficam locais de propósito: donate* (uso por dispositivo; sincronizar contaria em dobro), goLiveSignal (one-shot transitório), currentChannelId (é a aba DESTE dispositivo) e lastSeenVersion (cada navegador encontra o update no seu ritmo). De quebra, corrige o contador de uso da doação lendo `enabled` da área roaming — pós-migração ele leria undefined no local e contaria uso mesmo com a extensão desligada. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AiLQznJ15KbuK73b9YxAjS --- CHANGELOG.md | 12 ++++++ README.md | 2 + README.pt-BR.md | 2 + background.js | 5 ++- common.js | 99 ++++++++++++++++++++++++++++++++++++++++++++ content.js | 57 ++++++++++++++----------- popup.js | 41 ++++++++++++------ test/common.test.mjs | 32 ++++++++++++++ 8 files changed, 211 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f84d32f..93c8683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ Todas as mudanças relevantes deste projeto são documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/) e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). +## [Não lançado] + +### Adicionado + +- **Configurações que te acompanham (storage.sync)**: modo, indicadores, + memória por canal e tema agora seguem o perfil do navegador entre + computadores. Migração automática e única do `storage.local` na primeira + execução, com leitura de reserva no dado antigo até ela acontecer — nenhuma + chave é renomeada, e um downgrade encontra os padrões, nunca dado corrompido. + Ficam locais de propósito: contadores de doação (senão contariam em dobro), + o sinal de "ir ao vivo" e o canal da aba atual. + ## [1.4.0] - 2026-07-07 ### Adicionado diff --git a/README.md b/README.md index 2f90580..335f3da 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ YouTube player UI. - Keyboard shortcuts to toggle on/off (`Alt+Shift+Y`) and jump to live (`Alt+Shift+L`) — `⌘+Shift+…` on Mac — remappable at `chrome://extensions/shortcuts` +- Your mode, indicators and theme **follow your browser profile across + devices** (`storage.sync`) — donation choices and per-device state stay local ## Settings diff --git a/README.pt-BR.md b/README.pt-BR.md index ee7ed51..baeac40 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -39,6 +39,8 @@ YouTube. - Atalhos de teclado para ligar/desligar (`Alt+Shift+Y`) e pular para o ao vivo (`Alt+Shift+L`) — `⌘+Shift+…` no Mac —, reconfiguráveis em `chrome://extensions/shortcuts` +- Modo, indicadores e tema **acompanham seu perfil do navegador entre + computadores** (`storage.sync`) — doação e estado por dispositivo ficam locais ## Configurações diff --git a/background.js b/background.js index ceeecc1..26649d6 100644 --- a/background.js +++ b/background.js @@ -23,6 +23,7 @@ function evalBadge() { function boot() { common.ensureInstalledAt(); + common.ensureSettingsMigrated(); // one-time local→sync move (idempotent) chrome.alarms.create('donate-eval', { periodInMinutes: 30 }); evalBadge(); } @@ -82,11 +83,11 @@ function messageActiveTab(type) { */ function onCommand(command) { if (command === 'toggle-enabled') { - chrome.storage.local.get([...common.storage, common.lastModeKey], data => { + common.getSettings([...common.storage, common.lastModeKey], data => { const { apply, remember } = common.toggleEnabledAction(data, data[common.lastModeKey]); const patch = { ...apply }; if (remember) patch[common.lastModeKey] = remember; - chrome.storage.local.set(patch); + common.setSettings(patch); }); } else if (command === 'go-live') { messageActiveTab('go-live'); diff --git a/common.js b/common.js index e176ebc..73451de 100644 --- a/common.js +++ b/common.js @@ -300,6 +300,105 @@ export function forgetChannelMode(data, channelId) { return modes; } +// --------------------------------------------------------------------------- +// Settings roaming (chrome.storage.sync). The PREFERENCE-class keys — engine +// settings, last mode, channel memory, theme — follow the user's browser +// profile across devices. Device-local state stays in storage.local: +// • donate* keys (usage time is per-device; syncing would double-count), +// • goLiveSignal (a transient one-shot), +// • currentChannelId (literally "the channel of the tab last seen HERE"). +// Reads go through getSettings(), which falls back to storage.local until the +// one-time migration (ensureSettingsMigrated, run at worker boot and popup +// open) has moved existing users' data over. All keys keep their names — no +// rename, no reshape — so a downgrade simply finds defaults, never corruption. +// --------------------------------------------------------------------------- +export const syncedKeys = [...storage, lastModeKey, channelMemoryKey, channelModesKey, themeKey]; + +const hasChromeStorage = typeof chrome !== 'undefined' && !!chrome.storage; + +/** The area settings live in: sync when the browser offers it, else local. */ +export function settingsArea() { + return (hasChromeStorage && chrome.storage.sync) ? chrome.storage.sync : chrome.storage.local; +} + +/** + * Read settings-class keys. Prefers the sync area; when none of the requested + * keys exist there yet (pre-migration, or sync unavailable) it re-reads from + * storage.local so existing users never see a flash of defaults. + * @param {string[]} keys - Keys to read (settings-class only). + * @param {(items: Object) => void} cb - Receives the resolved values. + */ +export function getSettings(keys, cb) { + const area = settingsArea(); + area.get(keys, vals => { + if (area === chrome.storage.local) { cb(vals); return; } + const empty = keys.every(k => vals[k] === undefined); + if (!empty) { cb(vals); return; } + chrome.storage.local.get(keys, cb); + }); +} + +/** + * Write settings-class keys to the roaming area. Best-effort: a quota/offline + * error is swallowed (the keys are tiny; the realistic trip is the per-minute + * write limit, which a retry naturally clears). + * @param {Object} items - Key/value pairs to persist. + * @param {() => void} [cb] - Called after the write settles. + */ +export function setSettings(items, cb) { + settingsArea().set(items, () => { + void chrome.runtime.lastError; + if (cb) cb(); + }); +} + +/** + * Decide what the one-time local→sync migration should do. Pure, so the three + * outcomes stay unit-testable: + * • local has settings, sync has none -> copy them over, then clean local; + * • sync already has settings -> sync wins (another device migrated + * first); just clean local so reads stop being ambiguous; + * • local has nothing -> nothing to do. + * @param {Object} localData - Values read from storage.local. + * @param {Object} syncData - Values read from storage.sync. + * @param {string[]} [keys] - The settings-class key set. + * @returns {{copy: (Object|null), removeLocal: string[]}} + */ +export function planSyncMigration(localData, syncData, keys = syncedKeys) { + const localKeys = keys.filter(k => localData && localData[k] !== undefined); + if (!localKeys.length) return { copy: null, removeLocal: [] }; + const syncHas = keys.some(k => syncData && syncData[k] !== undefined); + if (syncHas) return { copy: null, removeLocal: localKeys }; + const copy = {}; + for (const k of localKeys) copy[k] = localData[k]; + return { copy, removeLocal: localKeys }; +} + +/** + * Run the local→sync migration once, idempotently. Safe to call from several + * contexts: after it has run, local holds none of the keys and the plan is a + * no-op. When the copy write fails (quota/offline) local is kept untouched so + * the next boot retries. + * @param {() => void} [done] - Called when the migration settles. + */ +export function ensureSettingsMigrated(done) { + if (!hasChromeStorage || !chrome.storage.sync) { if (done) done(); return; } + chrome.storage.local.get(syncedKeys, loc => { + chrome.storage.sync.get(syncedKeys, syn => { + const plan = planSyncMigration(loc, syn); + const clean = () => { + if (!plan.removeLocal.length) { if (done) done(); return; } + chrome.storage.local.remove(plan.removeLocal, () => { if (done) done(); }); + }; + if (!plan.copy) { clean(); return; } + chrome.storage.sync.set(plan.copy, () => { + if (chrome.runtime.lastError) { if (done) done(); return; } // retry next boot + clean(); + }); + }); + }); +} + // International donation links — shown to users whose browser is NOT in pt_BR // (see `isBrazil`). Replace the placeholders with your own usernames/links. // Leave a value as '' (or keep the REPLACE placeholder) to hide that button. diff --git a/content.js b/content.js index 8b4d5b0..a4082c0 100644 --- a/content.js +++ b/content.js @@ -22,7 +22,7 @@ const extensionAlive = () => Boolean(chrome.runtime?.id); function main(common) { function loadSettings() { if (!extensionAlive()) return; - chrome.storage.local.get(common.storage, data => { + common.getSettings(common.storage, data => { sendLoadSettingsEvent(common.resolveSettings(data)); }); } @@ -52,8 +52,9 @@ function main(common) { // Reload only when an engine setting actually changed — the donation // counter and control keys write storage frequently, and re-sending // settings to every YouTube frame on each of those writes is pure churn. + // Settings roam via storage.sync, but 'local' still matters pre-migration. function onEngineSettingsChanged(changes, area) { - if (area === 'local' && common.storage.some(k => k in changes)) loadSettings(); + if ((area === 'sync' || area === 'local') && common.storage.some(k => k in changes)) loadSettings(); } /** Tell the engine (inject.js) to jump to the live edge now. */ @@ -109,7 +110,7 @@ function main(common) { document.addEventListener('_live_catch_up_video_meta', e => onChannelIdUpdate(common, e.detail)); // Turning the feature ON should take effect on the channel you're already on. chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'local' && changes[common.channelMemoryKey]?.newValue && lastChannelId) { + if ((area === 'sync' || area === 'local') && changes[common.channelMemoryKey]?.newValue && lastChannelId) { onChannelIdUpdate(common, { channel_id: lastChannelId }); } }); @@ -164,18 +165,22 @@ function initDonation(common) { const usageTimer = setInterval(() => { if (!extensionAlive()) { clearInterval(usageTimer); return; } if (document.hidden || Date.now() - donateLastActive > 5000) return; - chrome.storage.local.get(['enabled', 'donateUsageSeconds', 'donateLastCountedAt'], d => { - if (!common.value(d.enabled, common.defaultEnabled)) return; - // Every YouTube tab runs this loop; only one may count each minute - // of wall-clock time, or N tabs would accrue N× the real usage. - // (5s of slack absorbs timer jitter between the tabs.) - const now = Date.now(); - if (now - (d.donateLastCountedAt || 0) < (TICK - 5) * 1000) return; - chrome.storage.local.set({ - donateUsageSeconds: (d.donateUsageSeconds || 0) + TICK, - donateLastCountedAt: now, + // `enabled` roams with the settings (sync); the usage counter itself is + // deliberately per-device local — syncing it would double-count time. + common.getSettings(['enabled'], s => { + if (!common.value(s.enabled, common.defaultEnabled)) return; + chrome.storage.local.get(['donateUsageSeconds', 'donateLastCountedAt'], d => { + // Every YouTube tab runs this loop; only one may count each minute + // of wall-clock time, or N tabs would accrue N× the real usage. + // (5s of slack absorbs timer jitter between the tabs.) + const now = Date.now(); + if (now - (d.donateLastCountedAt || 0) < (TICK - 5) * 1000) return; + chrome.storage.local.set({ + donateUsageSeconds: (d.donateUsageSeconds || 0) + TICK, + donateLastCountedAt: now, + }); + maybeShowBanner(common); }); - maybeShowBanner(common); }); }, TICK * 1000); } @@ -354,7 +359,7 @@ let stallOfferShown = false; function onStallDetected(common) { if (stallOfferShown || stallOfferEl || !extensionAlive()) return; - chrome.storage.local.get(common.storage, data => { + common.getSettings(common.storage, data => { const target = common.calmerMode(common.deriveMode(data)); if (!target) return; // already on the calmest mode (or off) showStallOffer(common, target); @@ -379,7 +384,7 @@ function showStallOffer(common, target) { stallOfferEl = buildOverlayCard({ side: 'left', maxWidth: '330px', content: body, ctaLabel: `${L.stallSwitch} ${common.modeMeta[target].title}`, - onCta: () => chrome.storage.local.set(common.presets[target]), + onCta: () => common.setSettings(common.presets[target]), closeLabel: L.donateBannerClose, autoHideMs: 14000, onRemove: () => { stallOfferEl = null; }, @@ -397,13 +402,17 @@ function onChannelIdUpdate(common, detail) { const channelId = detail && detail.channel_id; if (!channelId || !extensionAlive()) return; lastChannelId = channelId; - chrome.storage.local.get([common.channelMemoryKey, common.channelModesKey, common.currentChannelIdKey, ...common.storage], data => { - const upd = {}; - if (data[common.currentChannelIdKey] !== channelId) upd[common.currentChannelIdKey] = channelId; - if (data[common.channelMemoryKey]) { - const saved = common.getSuggestedModeForChannel(data, channelId); - if (saved && common.deriveMode(data) !== saved) Object.assign(upd, common.presets[saved]); - } - if (Object.keys(upd).length) chrome.storage.local.set(upd); + // The channel map + engine settings roam (storage.sync); currentChannelId + // is this device's "channel of the tab last seen" and stays local. + common.getSettings([common.channelMemoryKey, common.channelModesKey, ...common.storage], data => { + chrome.storage.local.get([common.currentChannelIdKey], meta => { + if (meta[common.currentChannelIdKey] !== channelId) { + chrome.storage.local.set({ [common.currentChannelIdKey]: channelId }); + } + if (data[common.channelMemoryKey]) { + const saved = common.getSuggestedModeForChannel(data, channelId); + if (saved && common.deriveMode(data) !== saved) common.setSettings(common.presets[saved]); + } + }); }); } diff --git a/popup.js b/popup.js index 098c4e5..359e24f 100644 --- a/popup.js +++ b/popup.js @@ -57,7 +57,9 @@ function sanitizeSvg(node) { for (const child of [...node.children]) sanitizeSvg(child); } -const getStorage = keys => new Promise(res => chrome.storage.local.get(keys, res)); +// Settings-class keys roam via storage.sync (with a pre-migration fallback to +// local) — see common.getSettings. Donation/meta keys keep using storage.local. +const getStorage = keys => new Promise(res => common.getSettings(keys, res)); /** * Wire ARIA radiogroup keyboard behavior: one tab-stop for the group (roving @@ -165,18 +167,18 @@ let rovingModes = null; // wireRadiogroup roving-tabindex setter for modes function setOne(key, val) { state[key] = val; - chrome.storage.local.set({ [key]: val }); + common.setSettings({ [key]: val }); refresh(); } function applyPreset(name) { const preset = common.presets[name]; - chrome.storage.local.set(preset); + common.setSettings(preset); Object.assign(state, preset); // Per-channel memory (opt-in): record this EXPLICIT pick for the current channel. if (channelMemoryOn && currentChannelId && name !== 'off') { channelModes = common.saveChannelMode({ [common.channelModesKey]: channelModes }, currentChannelId, name); - chrome.storage.local.set({ [common.channelModesKey]: channelModes }); + common.setSettings({ [common.channelModesKey]: channelModes }); } refresh(); } @@ -366,6 +368,8 @@ function renderReset() { function doReset() { // Only clear engine settings — keep the donation opt-out / snooze choices. + // Both areas: sync is where they live now, local catches pre-migration data. + common.settingsArea().remove(common.storage); chrome.storage.local.remove(common.storage); state = common.resolveSettings({}); refresh(); @@ -604,7 +608,7 @@ function renderThemeToggle() { btn.setAttribute('aria-label', title); }; relabel(); - chrome.storage.local.get([common.themeKey], d => { + common.getSettings([common.themeKey], d => { const saved = d[common.themeKey]; if (saved === 'light' || saved === 'dark') root.dataset.theme = saved; relabel(); @@ -612,7 +616,7 @@ function renderThemeToggle() { btn.addEventListener('click', () => { const next = current() === 'dark' ? 'light' : 'dark'; root.dataset.theme = next; - chrome.storage.local.set({ [common.themeKey]: next }); + common.setSettings({ [common.themeKey]: next }); relabel(); }); } @@ -645,7 +649,7 @@ function refresh() { function renderChannelMemory() { const rows = $('#channel-memory-rows'); if (!rows) return; - const input = el('input', { type: 'checkbox', onchange: () => chrome.storage.local.set({ [common.channelMemoryKey]: input.checked }) }); + const input = el('input', { type: 'checkbox', onchange: () => common.setSettings({ [common.channelMemoryKey]: input.checked }) }); const sw = el('label', { class: 'switch' }, input, el('span', { class: 'track' }), el('span', { class: 'thumb' })); const row = buildRow({ label: L.channelMemoryLabel, control: sw }); row.querySelector('.row-label').title = L.channelMemoryHint; @@ -657,9 +661,11 @@ function renderChannelMemory() { forget.addEventListener('click', () => { if (!currentChannelId) return; channelModes = common.forgetChannelMode({ [common.channelModesKey]: channelModes }, currentChannelId); - chrome.storage.local.set({ [common.channelModesKey]: channelModes }); + common.setSettings({ [common.channelModesKey]: channelModes }); }); + // The toggle + map roam (storage.sync); currentChannelId is this device's + // "channel of the tab last seen" and stays in storage.local. const sync = d => { channelMemoryOn = d[common.channelMemoryKey] == null ? common.defaultChannelMemory : !!d[common.channelMemoryKey]; input.checked = channelMemoryOn; @@ -667,10 +673,14 @@ function renderChannelMemory() { currentChannelId = d[common.currentChannelIdKey] || null; updateChannelHint(); }; - const keys = [common.channelMemoryKey, common.channelModesKey, common.currentChannelIdKey]; - chrome.storage.local.get(keys, sync); + const roamKeys = [common.channelMemoryKey, common.channelModesKey]; + const readAll = () => common.getSettings(roamKeys, roamed => { + chrome.storage.local.get([common.currentChannelIdKey], meta => sync({ ...roamed, ...meta })); + }); + readAll(); chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'local' && keys.some(k => k in changes)) chrome.storage.local.get(keys, sync); + if (area !== 'sync' && area !== 'local') return; + if ([...roamKeys, common.currentChannelIdKey].some(k => k in changes)) readAll(); }); } @@ -688,6 +698,10 @@ function updateChannelHint() { // screen readers and hyphenation (popup.html can't hardcode one). document.documentElement.lang = chrome.i18n.getUILanguage() || 'en'; + // Belt-and-braces with the worker's boot: make sure existing users' data + // has moved to the roaming area before the first read or write from here. + await new Promise(res => common.ensureSettingsMigrated(res)); + const data = await getStorage(common.storage); state = common.resolveSettings(data); renderStatic(); @@ -703,9 +717,10 @@ function updateChannelHint() { refresh(); // Keep the UI in sync with changes made elsewhere while the popup is open - // (keyboard shortcut, the player's stall offer, another options window). + // (keyboard shortcut, the player's stall offer, another options window — + // and now another DEVICE, via the sync area). chrome.storage.onChanged.addListener((changes, area) => { - if (area !== 'local' || !common.storage.some(k => k in changes)) return; + if ((area !== 'sync' && area !== 'local') || !common.storage.some(k => k in changes)) return; getStorage(common.storage).then(d => { state = common.resolveSettings(d); refresh(); diff --git a/test/common.test.mjs b/test/common.test.mjs index a3eb184..53b7b64 100644 --- a/test/common.test.mjs +++ b/test/common.test.mjs @@ -170,3 +170,35 @@ test('Personalizado: derives at any slider position, distinct from the classic m assert.equal(common.resolveSettings({}).band, false); assert.equal(common.deriveMode({}), 'auto'); }); + +test('syncedKeys roam the preference-class keys and nothing device-local', () => { + // Engine settings + last mode + channel memory + theme roam... + for (const k of [...common.storage, common.lastModeKey, common.channelMemoryKey, common.channelModesKey, common.themeKey]) { + assert.ok(common.syncedKeys.includes(k), `"${k}" should roam`); + } + // ...while per-device state must never: donation counters would + // double-count, the go-live nonce is transient, and currentChannelId is + // literally "this device's tab". + for (const k of [...common.donateKeys, common.goLiveSignalKey, common.currentChannelIdKey]) { + assert.ok(!common.syncedKeys.includes(k), `"${k}" must stay local`); + } +}); + +test('planSyncMigration copies local settings when sync is empty, then cleans local', () => { + const local = { enabled: true, bufferTarget: 4.0, lastMode: 'balanced', donateOptOut: true }; + const plan = common.planSyncMigration(local, {}); + assert.deepEqual(plan.copy, { enabled: true, bufferTarget: 4.0, lastMode: 'balanced' }, 'donate keys never migrate'); + assert.deepEqual(plan.removeLocal.sort(), ['bufferTarget', 'enabled', 'lastMode']); +}); + +test('planSyncMigration lets an already-populated sync win (first device migrated)', () => { + const plan = common.planSyncMigration({ enabled: false }, { enabled: true, auto: true }); + assert.equal(plan.copy, null, 'nothing is copied over the sync data'); + assert.deepEqual(plan.removeLocal, ['enabled'], 'stale local copy is cleaned'); +}); + +test('planSyncMigration is a no-op without local settings', () => { + assert.deepEqual(common.planSyncMigration({}, {}), { copy: null, removeLocal: [] }); + assert.deepEqual(common.planSyncMigration({ donateUsageSeconds: 99 }, {}), { copy: null, removeLocal: [] }); + assert.deepEqual(common.planSyncMigration(null, null), { copy: null, removeLocal: [] }); +});