diff --git a/CHANGELOG.md b/CHANGELOG.md index bb163da5..0d78c19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Version 3.3.5 +### Extension Update Reminders + +- **Refresh reminders can be disabled**: Settings now includes a device-wide control for the reminder shown on YouTube tabs that were already open when FilterTube updates. The reminder remains enabled by default, and a disabled preference is preserved across later updates. + ### YouTube Breakage Recovery - **Duplicate content-runtime injection fixed**: install/update/profile refresh now relies on manifest content scripts and ping/bridge checks instead of reinjecting isolated runtime files into already-open YouTube tabs. This prevents repeated `Identifier ... has already been declared` syntax failures such as `filterTubeMenuStylesInjected`, `VIDEO_CARD_SELECTORS`, `CHANNEL_ONLY_TAGS`, `pendingSeedSettings`, and `statsCountToday`. diff --git a/data/release_notes.json b/data/release_notes.json index 721fe248..80698c29 100644 --- a/data/release_notes.json +++ b/data/release_notes.json @@ -8,6 +8,7 @@ "summary": "This release fixes duplicate content-runtime injection in already-open YouTube tabs and resolves visible Shorts channel identity earlier when channel rules or whitelist mode are active.", "bannerSummary": "Fixes duplicate runtime injection, improves blocked-channel Shorts identity resolution, and documents the v3.3.4 rollback boundary.", "highlights": [ + "Settings now includes a device-wide control for post-update refresh reminders; it remains enabled by default and a disabled preference is preserved across later updates.", "Install/update/profile refresh no longer reinjects manifest content scripts into already-open YouTube tabs, preventing repeated `Identifier ... has already been declared` syntax failures.", "Already-open YouTube tabs can still receive managed time-limit/runtime checks without duplicate script declarations.", "Visible and near-visible Shorts now use the existing bounded background `/shorts/` owner resolver when channel blocklist rules or whitelist mode are active.", diff --git a/docs/audit/FILTERTUBE_UPDATE_REFRESH_NOTIFICATION_RELEASE_SETTING_2026-07-20.md b/docs/audit/FILTERTUBE_UPDATE_REFRESH_NOTIFICATION_RELEASE_SETTING_2026-07-20.md new file mode 100644 index 00000000..ff7719ff --- /dev/null +++ b/docs/audit/FILTERTUBE_UPDATE_REFRESH_NOTIFICATION_RELEASE_SETTING_2026-07-20.md @@ -0,0 +1,48 @@ +# FilterTube Update Refresh Notification Release Setting - 2026-07-20 + +Status: implementation proof for issue #37. + +## Behavior + +FilterTube keeps its existing post-update refresh reminder enabled by default. +Settings now exposes a device-wide checkbox that can suppress that reminder on +already-open YouTube and YouTube Kids tabs. The preference is stored separately +from `firstRunRefreshNeeded`, so disabling reminders does not erase pending +update state and a later re-enable can show the reminder again. + +Fresh installs write `showUpdateRefreshPrompt: true`. Existing installations +without the key also read as enabled. The update path does not overwrite the +key, preserving an explicit `false` choice across extension updates. + +## Source Boundary + +- `html/tab-view.html` owns the Settings checkbox. +- `js/tab-view.js` loads and persists the preference, defaults read failures to + enabled, and restores the checkbox after failed writes. +- `js/background.js` combines the preference with `firstRunRefreshNeeded` + before answering `FilterTube_FirstRunCheck`. +- `js/content/first_run_prompt.js` remains the existing prompt renderer and + completion owner; it requires no behavior change. + +## Automated Proof + +`tests/runtime/update-refresh-notification-setting.test.mjs` executes the +production preference helpers and checks: + +- the checkbox exists and its initializer is wired into dashboard startup; +- missing, true, false, and read-error loading behavior; +- persistence of both checkbox choices and failed-write rollback; +- the background decision matrix and asynchronous message response contract; +- the fresh-install default and absence of preference overwrite on update. + +Focused command: + +```text +node --test tests/runtime/update-refresh-notification-setting.test.mjs +``` + +## Manual Boundary + +No installed-extension visual smoke is claimed by this proof. Automated checks +cover the persisted decision and wiring; final placement, label readability, +and browser-rendered interaction remain manual release verification. diff --git a/html/tab-view.html b/html/tab-view.html index 075c4df9..c73f8ae9 100644 --- a/html/tab-view.html +++ b/html/tab-view.html @@ -879,6 +879,22 @@

Backups

+
+
+

Extension updates

+
+
+
+
+
Show refresh reminders after updates
+
+ +
+
+
diff --git a/js/background.js b/js/background.js index 9ecb754f..03cf382f 100644 --- a/js/background.js +++ b/js/background.js @@ -3499,6 +3499,12 @@ async function getCompiledSettings(sender = null, profileType = null, forceRefre } const FILTERTUBE_YOUTUBE_TAB_URLS = ['*://*.youtube.com/*', '*://*.youtubekids.com/*']; +const SHOW_UPDATE_REFRESH_PROMPT_KEY = 'showUpdateRefreshPrompt'; + +function shouldShowUpdateRefreshPrompt(settings = {}) { + return settings?.[SHOW_UPDATE_REFRESH_PROMPT_KEY] !== false + && settings?.firstRunRefreshNeeded !== false; +} function refreshYouTubeTabs() { try { @@ -3530,6 +3536,7 @@ browserAPI.runtime.onInstalled.addListener(function (details) { hideAllShorts: false, showQuickBlockButton: true, showBlockMenuItem: true, + [SHOW_UPDATE_REFRESH_PROMPT_KEY]: true, firstRunRefreshNeeded: true, releaseNotesSeenVersion: CURRENT_VERSION, releaseNotesPayload: null @@ -4109,8 +4116,10 @@ browserAPI.runtime.onMessage.addListener(function (request, sender, sendResponse }); return true; } else if (action === 'FilterTube_FirstRunCheck') { - storageGet(['firstRunRefreshNeeded']).then((data) => { - sendResponse?.({ needed: data?.firstRunRefreshNeeded !== false }); + storageGet(['firstRunRefreshNeeded', SHOW_UPDATE_REFRESH_PROMPT_KEY]).then((data) => { + sendResponse?.({ + needed: shouldShowUpdateRefreshPrompt(data) + }); }).catch(() => sendResponse?.({ needed: false })); return true; } else if (action === 'FilterTube_FirstRunComplete') { diff --git a/js/tab-view.js b/js/tab-view.js index d5b0e332..7eabea81 100644 --- a/js/tab-view.js +++ b/js/tab-view.js @@ -7,6 +7,35 @@ const FILTERTUBE_SEMANTIC_ML_ENABLED = false; const ANDROID_CLOSED_TESTING_INVITE_DISMISSED_KEY = 'filtertube_android_closed_testing_invite_dismissed_v1'; +const SHOW_UPDATE_REFRESH_PROMPT_KEY = 'showUpdateRefreshPrompt'; + +async function initializeUpdateRefreshPromptSetting(setting) { + if (!setting) return; + + try { + const stored = await runtimeAPI?.storage?.local?.get([SHOW_UPDATE_REFRESH_PROMPT_KEY]); + setting.checked = stored?.[SHOW_UPDATE_REFRESH_PROMPT_KEY] !== false; + } catch (e) { + setting.checked = true; + } + + setting.addEventListener('change', async () => { + try { + await runtimeAPI?.storage?.local?.set({ + [SHOW_UPDATE_REFRESH_PROMPT_KEY]: setting.checked + }); + UIComponents.showToast( + setting.checked + ? 'Update refresh reminders enabled' + : 'Update refresh reminders disabled', + setting.checked ? 'success' : 'info' + ); + } catch (e) { + setting.checked = !setting.checked; + UIComponents.showToast('Could not update refresh reminders', 'error'); + } + }); +} // ============================================================================ // FILTERS TAB INITIALIZATION @@ -3138,6 +3167,7 @@ document.addEventListener('DOMContentLoaded', async () => { const ftAutoBackupMode = document.getElementById('ftAutoBackupMode'); const ftAutoBackupFormat = document.getElementById('ftAutoBackupFormat'); const settingAutoBackupEnabled = document.getElementById('setting_autoBackupEnabled'); + const settingShowUpdateRefreshPrompt = document.getElementById('setting_showUpdateRefreshPrompt'); const ftProfileSelector = document.getElementById('ftProfileSelector'); const ftProfileMenuTab = document.getElementById('ftProfileMenuTab'); const ftProfileBadgeBtnTab = document.getElementById('ftProfileBadgeBtnTab'); @@ -3387,6 +3417,8 @@ document.addEventListener('DOMContentLoaded', async () => { // Load initial settings await StateManager.loadSettings(); + await initializeUpdateRefreshPromptSetting(settingShowUpdateRefreshPrompt); + // Apply theme immediately const state = StateManager.getState(); if (state.theme) { diff --git a/tests/runtime/update-refresh-notification-setting.test.mjs b/tests/runtime/update-refresh-notification-setting.test.mjs new file mode 100644 index 00000000..cef61cfc --- /dev/null +++ b/tests/runtime/update-refresh-notification-setting.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); + +const backgroundSource = read('js/background.js'); +const dashboardSource = read('js/tab-view.js'); +const dashboardHtml = read('html/tab-view.html'); + +function sectionBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + assert.notEqual(start, -1, `missing start marker: ${startMarker}`); + const end = source.indexOf(endMarker, start + startMarker.length); + assert.notEqual(end, -1, `missing end marker: ${endMarker}`); + return source.slice(start, end); +} + +function extractFunction(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, `missing function: ${signature}`); + + const openBrace = source.indexOf('{', start + signature.length); + assert.notEqual(openBrace, -1, `missing opening brace: ${signature}`); + + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] === '}') depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + + assert.fail(`missing closing brace: ${signature}`); +} + +function makeCheckbox() { + return { + checked: false, + listeners: new Map(), + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + }; +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function loadDashboardSettingRuntime({ stored = {}, getError = null, setError = null } = {}) { + const writes = []; + const toasts = []; + const context = { + runtimeAPI: { + storage: { + local: { + async get() { + if (getError) throw getError; + return stored; + }, + async set(value) { + writes.push(value); + if (setError) throw setError; + } + } + } + }, + UIComponents: { + showToast(message, type) { + toasts.push({ message, type }); + } + } + }; + vm.createContext(context); + vm.runInContext(` + const SHOW_UPDATE_REFRESH_PROMPT_KEY = 'showUpdateRefreshPrompt'; + ${extractFunction(dashboardSource, 'async function initializeUpdateRefreshPromptSetting(setting)')} + this.initializeSetting = initializeUpdateRefreshPromptSetting; + `, context); + return { context, writes, toasts }; +} + +function loadBackgroundDecisionRuntime() { + const context = {}; + vm.createContext(context); + vm.runInContext(` + const SHOW_UPDATE_REFRESH_PROMPT_KEY = 'showUpdateRefreshPrompt'; + ${extractFunction(backgroundSource, 'function shouldShowUpdateRefreshPrompt(settings = {})')} + this.shouldShow = shouldShowUpdateRefreshPrompt; + `, context); + return context.shouldShow; +} + +test('dashboard exposes the update refresh reminder setting', () => { + assert.match( + dashboardHtml, + // + ); +}); + +test('dashboard setting loads default-on and persists both choices', async () => { + for (const [stored, initialValue] of [ + [{}, true], + [{ showUpdateRefreshPrompt: true }, true], + [{ showUpdateRefreshPrompt: false }, false] + ]) { + const checkbox = makeCheckbox(); + const runtime = loadDashboardSettingRuntime({ stored }); + await runtime.context.initializeSetting(checkbox); + + assert.equal(checkbox.checked, initialValue); + assert.equal(typeof checkbox.listeners.get('change'), 'function'); + + checkbox.checked = !initialValue; + await checkbox.listeners.get('change')(); + assert.deepEqual(plain(runtime.writes), [{ showUpdateRefreshPrompt: !initialValue }]); + assert.deepEqual(runtime.toasts, [{ + message: !initialValue + ? 'Update refresh reminders enabled' + : 'Update refresh reminders disabled', + type: !initialValue ? 'success' : 'info' + }]); + } +}); + +test('dashboard setting defaults on after a read error and rolls back a failed write', async () => { + const checkbox = makeCheckbox(); + const runtime = loadDashboardSettingRuntime({ + getError: new Error('read failed'), + setError: new Error('write failed') + }); + await runtime.context.initializeSetting(checkbox); + + assert.equal(checkbox.checked, true); + checkbox.checked = false; + await checkbox.listeners.get('change')(); + + assert.equal(checkbox.checked, true); + assert.deepEqual(plain(runtime.writes), [{ showUpdateRefreshPrompt: false }]); + assert.deepEqual(runtime.toasts, [{ + message: 'Could not update refresh reminders', + type: 'error' + }]); +}); + +test('background decision requires both the preference and pending refresh flag', () => { + const shouldShow = loadBackgroundDecisionRuntime(); + + assert.equal(shouldShow({}), true); + assert.equal(shouldShow({ showUpdateRefreshPrompt: true, firstRunRefreshNeeded: true }), true); + assert.equal(shouldShow({ showUpdateRefreshPrompt: false, firstRunRefreshNeeded: true }), false); + assert.equal(shouldShow({ showUpdateRefreshPrompt: true, firstRunRefreshNeeded: false }), false); + assert.equal(shouldShow({ showUpdateRefreshPrompt: false, firstRunRefreshNeeded: false }), false); +}); + +test('background message path reads both inputs and delegates to the tested decision', () => { + const installBlock = sectionBetween( + backgroundSource, + "if (details.reason === 'install') {", + "} else if (details.reason === 'update') {" + ); + assert.match(installBlock, /\[SHOW_UPDATE_REFRESH_PROMPT_KEY\]: true/); + + const checkBlock = sectionBetween( + backgroundSource, + "} else if (action === 'FilterTube_FirstRunCheck') {", + "} else if (action === 'FilterTube_FirstRunComplete') {" + ); + assert.match(checkBlock, /storageGet\(\['firstRunRefreshNeeded', SHOW_UPDATE_REFRESH_PROMPT_KEY\]\)/); + assert.match(checkBlock, /needed: shouldShowUpdateRefreshPrompt\(data\)/); + assert.match(checkBlock, /return true;/); + + assert.match( + dashboardSource, + /const settingShowUpdateRefreshPrompt = document\.getElementById\('setting_showUpdateRefreshPrompt'\);/ + ); + assert.match( + dashboardSource, + /await initializeUpdateRefreshPromptSetting\(settingShowUpdateRefreshPrompt\);/ + ); + + const updateBlock = sectionBetween( + backgroundSource, + "} else if (details.reason === 'update') {", + "browserAPI.runtime.onMessage.addListener" + ); + assert.doesNotMatch(updateBlock, /SHOW_UPDATE_REFRESH_PROMPT_KEY/); +});