Harden host access recovery for v2.6.2

This commit is contained in:
Timo
2026-07-15 12:46:57 +02:00
parent a334605dd5
commit 142153a131
7 changed files with 278 additions and 125 deletions
+125 -32
View File
@@ -4,7 +4,7 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
import { initTabManager } from './modules/tab-manager.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
import './page-api-seek-overrides.js';
// --- Uninstall URL Initialization ---
@@ -170,8 +170,12 @@ function ensureState() {
], (data) => {
clearTimeout(storageTimeout);
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
if (data.currentTabTitle !== undefined) {
currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string'
? data.currentTabTitle
: null;
}
// Merge data from storage with any early-arriving state
// New entries (added during boot) must stay at the top (index 0)
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
@@ -1770,6 +1774,9 @@ function injectionFailureResponse(error) {
}
async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) throw new Error('Invalid tab ID');
tabId = normalizedTabId;
let needsPageApiSeek = false;
let pageApiSeekReady = false;
let access = null;
@@ -1817,9 +1824,18 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
// A temporary activeTab grant is intentionally allowed to win: even if
// permissions.contains() reports false, a successful injection above is
// valid. Only convert an actual injection failure into a host-access UX.
if (access?.originPattern && access.granted === false) {
try {
// The tab may have crossed origins between the initial permission
// check and executeScript(). Always report/request the current URL.
access = await inspectTabHostAccess(chrome, tabId);
} catch (_inspectionError) {
access = null;
}
const accessIsMissing = access?.granted === false
|| (access?.granted === null && isHostAccessError(error));
if (access?.originPattern && accessIsMissing) {
const requestAdded = requestHostAccess
? await addTabHostAccessRequest(chrome, tabId)
? await addTabHostAccessRequest(chrome, tabId, access.originPattern)
: false;
throw createHostAccessRequiredError(access, requestAdded, error);
}
@@ -1827,31 +1843,61 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
}
}
let pendingTargetMutation = Promise.resolve();
function mutatePendingTarget(operation) {
const result = pendingTargetMutation.catch(() => {}).then(operation);
pendingTargetMutation = result.catch(() => {});
return result;
}
async function rememberPendingTarget(tabId, tabTitle, error) {
const previous = await chrome.storage.session.get('pendingTargetTabId');
const previousTabId = normalizeTabId(previous.pendingTargetTabId);
if (previousTabId !== null && previousTabId !== tabId) {
await removeTabHostAccessRequest(chrome, previousTabId);
}
await chrome.storage.session.set({
pendingTargetTabId: tabId,
pendingTargetTabTitle: tabTitle || null,
pendingTargetHost: error?.host || null,
pendingTargetOriginPattern: error?.originPattern || null
return mutatePendingTarget(async () => {
const previous = await chrome.storage.session.get([
'pendingTargetTabId',
'pendingTargetOriginPattern'
]);
const previousTabId = normalizeTabId(previous.pendingTargetTabId);
const nextOriginPattern = error?.originPattern || null;
if (previousTabId !== null && (
previousTabId !== tabId
|| previous.pendingTargetOriginPattern !== nextOriginPattern
)) {
await removeTabHostAccessRequest(
chrome,
previousTabId,
previous.pendingTargetOriginPattern || null
);
}
await chrome.storage.session.set({
pendingTargetTabId: tabId,
pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null,
pendingTargetHost: error?.host || null,
pendingTargetOriginPattern: nextOriginPattern
});
});
}
async function clearPendingTarget() {
const pending = await chrome.storage.session.get('pendingTargetTabId');
const pendingTabId = normalizeTabId(pending.pendingTargetTabId);
if (pendingTabId !== null) {
await removeTabHostAccessRequest(chrome, pendingTabId);
}
await chrome.storage.session.set({
pendingTargetTabId: null,
pendingTargetTabTitle: null,
pendingTargetHost: null,
pendingTargetOriginPattern: null
return mutatePendingTarget(async () => {
const pending = await chrome.storage.session.get([
'pendingTargetTabId',
'pendingTargetOriginPattern'
]);
const pendingTabId = normalizeTabId(pending.pendingTargetTabId);
if (pendingTabId !== null) {
await removeTabHostAccessRequest(
chrome,
pendingTabId,
pending.pendingTargetOriginPattern || null
);
}
await chrome.storage.session.set({
pendingTargetTabId: null,
pendingTargetTabTitle: null,
pendingTargetHost: null,
pendingTargetOriginPattern: null
});
});
}
@@ -1862,7 +1908,7 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } =
}
const activationGeneration = ++targetActivationGeneration;
const previousTabId = currentTabId;
const previousTabId = normalizeTabId(currentTabId);
try {
await injectContentScript(selectedTabId, { requestHostAccess });
@@ -1872,6 +1918,7 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } =
}
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
@@ -1882,10 +1929,17 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } =
roomIdleSince,
lastContentHeartbeatAt: null
});
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
updateBadgeStatus();
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
await rememberPendingTarget(selectedTabId, tabTitle, error);
chrome.runtime.sendMessage({
type: 'TARGET_TAB_ACCESS_REQUIRED',
...injectionFailureResponse(error)
}).catch(() => {});
} else {
await clearPendingTarget();
}
@@ -1897,15 +1951,23 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } =
return { status: 'superseded' };
}
await applyAudioSettingsToTab(selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
currentTabId = selectedTabId;
currentTabTitle = tabTitle || null;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId && previousTabId !== selectedTabId) {
resetAudioProcessingInTab(previousTabId);
}
await applyAudioSettingsToTab(selectedTabId);
await clearPendingTarget();
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
@@ -1950,6 +2012,36 @@ if (chrome.permissions?.onAdded?.addListener) {
});
}
if (chrome.tabs?.onRemoved?.addListener) {
chrome.tabs.onRemoved.addListener((removedTabId) => {
ensureState().then(async () => {
const tabId = normalizeTabId(removedTabId);
if (tabId === null) return;
const pending = await chrome.storage.session.get('pendingTargetTabId');
const isCurrent = normalizeTabId(currentTabId) === tabId;
const isPending = normalizeTabId(pending.pendingTargetTabId) === tabId;
if (!isCurrent && !isPending) return;
targetActivationGeneration++;
if (isCurrent) {
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
}
if (isPending) await clearPendingTarget();
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
roomIdleSince,
lastContentHeartbeatAt
});
updateBadgeStatus();
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {});
}).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn'));
});
}
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
@@ -1964,7 +2056,8 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
return;
}
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
injectContentScript(tabId).then(() => {
activateTargetTab(tabId, currentTabTitle).then(response => {
if (response?.status !== 'ok') return;
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
}).catch(_err => {
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
@@ -2618,8 +2711,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return true;
}
injectContentScript(tabId).then(() => {
sendResponse({ status: 'ok' });
activateTargetTab(tabId, currentTabTitle).then(response => {
sendResponse(response);
}).catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
sendResponse(injectionFailureResponse(err));
+92 -12
View File
@@ -1,9 +1,15 @@
export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required';
export function normalizeTabId(value) {
if (value === null || value === undefined || value === '') return null;
const tabId = Number(value);
return Number.isInteger(tabId) && tabId > 0 ? tabId : null;
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value > 0 ? value : null;
}
if (typeof value !== 'string') return null;
const normalized = value.trim();
if (!/^[1-9]\d*$/.test(normalized)) return null;
const tabId = Number(normalized);
return Number.isSafeInteger(tabId) ? tabId : null;
}
export function describeTabUrl(rawUrl) {
@@ -34,7 +40,7 @@ export function describeTabUrl(rawUrl) {
export async function inspectTabHostAccess(chromeApi, tabId) {
const tab = await chromeApi.tabs.get(tabId);
const descriptor = describeTabUrl(tab?.url || '');
const descriptor = describeTabUrl(tab?.pendingUrl || tab?.url || '');
if (!descriptor) {
return {
tab,
@@ -50,10 +56,16 @@ export async function inspectTabHostAccess(chromeApi, tabId) {
}
try {
const granted = await chromeApi.permissions.contains({
origins: [descriptor.originPattern]
});
return { tab, ...descriptor, granted: granted === true };
const granted = await callBooleanPermissionMethod(
chromeApi,
'contains',
{ origins: [descriptor.originPattern] }
);
return {
tab,
...descriptor,
granted: granted === null ? null : granted === true
};
} catch (_e) {
// Permission inspection is advisory. The actual script injection below
// remains the source of truth, including temporary activeTab access.
@@ -61,26 +73,94 @@ export async function inspectTabHostAccess(chromeApi, tabId) {
}
}
export async function addTabHostAccessRequest(chromeApi, tabId) {
function callBooleanPermissionMethod(chromeApi, methodName, request) {
const method = chromeApi.permissions?.[methodName];
if (typeof method !== 'function') return Promise.resolve(null);
return new Promise((resolve, reject) => {
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value === true);
};
const fail = (error) => {
if (settled) return;
settled = true;
reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed')));
};
const callback = (value) => {
const lastError = chromeApi.runtime?.lastError;
if (lastError) {
fail(new Error(lastError.message || String(lastError)));
return;
}
finish(value);
};
try {
const result = method.call(chromeApi.permissions, request, callback);
if (result && typeof result.then === 'function') {
result.then(finish, fail);
} else if (typeof result === 'boolean') {
finish(result);
}
// Callback-based Chromium/Firefox APIs return undefined and settle
// through callback. Promise-based implementations settle above.
} catch (error) {
fail(error);
}
});
}
export function requestOriginPermission(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) {
return Promise.resolve(null);
}
return callBooleanPermissionMethod(
chromeApi,
'request',
{ origins: [originPattern] }
).catch(() => false);
}
export function isHostAccessError(error) {
const message = typeof error?.message === 'string' ? error.message : String(error || '');
return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
}
function hostAccessRequestDetails(tabId, originPattern) {
const request = { tabId };
if (typeof originPattern === 'string' && originPattern) {
request.pattern = originPattern;
}
return request;
}
export async function addTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.addHostAccessRequest({ tabId });
await chromeApi.permissions.addHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
}
}
export async function removeTabHostAccessRequest(chromeApi, tabId) {
export async function removeTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.removeHostAccessRequest({ tabId });
await chromeApi.permissions.removeHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
+21 -13
View File
@@ -3,6 +3,7 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
import { requestOriginPermission } from './host-access.js';
const elements = {
@@ -591,16 +592,6 @@ function handleTargetTabResponse(response) {
return false;
}
async function requestSiteAccess(originPattern) {
if (!originPattern || typeof chrome.permissions?.request !== 'function') return null;
try {
return await chrome.permissions.request({ origins: [originPattern] });
} catch (error) {
console.warn('[Popup] Host permission request failed:', error.message);
return false;
}
}
function selectTargetTab(tabId, tabTitle) {
return new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => {
@@ -1847,10 +1838,21 @@ if (elements.siteAccessRetry) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
}
const requestedOriginPattern = siteAccessOriginPattern;
elements.siteAccessRetry.disabled = true;
await requestSiteAccess(siteAccessOriginPattern);
await selectTargetTab(tabId, tabTitle);
elements.siteAccessRetry.disabled = false;
elements.targetTab.disabled = true;
try {
await requestOriginPermission(chrome, requestedOriginPattern);
const selectedTabId = parseInt(elements.targetTab.value);
// permissions.onAdded may already have completed activation, or a
// newer selection may have replaced this request while it was open.
if (!siteAccessBlocked || selectedTabId !== tabId
|| siteAccessOriginPattern !== requestedOriginPattern) return;
await selectTargetTab(tabId, tabTitle);
} finally {
elements.siteAccessRetry.disabled = false;
elements.targetTab.disabled = false;
}
});
}
@@ -2251,6 +2253,12 @@ chrome.runtime.onMessage.addListener((msg) => {
} else if (msg.type === 'TARGET_TAB_READY') {
hideSiteAccessNotice();
populateTabs(null, msg.tabId);
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
showSiteAccessNotice(msg.host, msg.originPattern);
populateTabs(null, msg.tabId);
} else if (msg.type === 'TARGET_TAB_CLEARED') {
hideSiteAccessNotice();
populateTabs();
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {