Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions data/release_notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/<videoId>` owner resolver when channel blocklist rules or whitelist mode are active.",
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions html/tab-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,22 @@ <h3>Backups</h3>
</div>
</div>
</div>
<div class="card settings-card">
<div class="card-header">
<h3>Extension updates</h3>
</div>
<div class="card-body settings-card__body">
<div class="toggle-row" title="Show a reminder on YouTube tabs that were already open when FilterTube updated.">
<div class="toggle-info">
<div class="toggle-title">Show refresh reminders after updates</div>
</div>
<label class="switch">
<input id="setting_showUpdateRefreshPrompt" type="checkbox" />
<span class="slider round"></span>
</label>
</div>
</div>
</div>
</div>
</div>

Expand Down
13 changes: 11 additions & 2 deletions js/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand Down
32 changes: 32 additions & 0 deletions js/tab-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
192 changes: 192 additions & 0 deletions tests/runtime/update-refresh-notification-setting.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
/<input id="setting_showUpdateRefreshPrompt" type="checkbox"\s*\/>/
);
});

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/);
});