mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-07 09:53:13 +00:00
Harden host access recovery for v2.6.2
This commit is contained in:
@@ -4,6 +4,18 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.2] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- **Website: Site-access recovery guide** — Added an English help page and a localized banner across every landing-page language.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Withheld website access recovery** — Detects browser-withheld host access, shows a localized allow-access action, requests only the selected website origin, and resumes the selected tab after access is granted.
|
||||
- **Extension: Target-tab reliability** — Hardened permission recovery against rapid tab changes, navigation, closed tabs, service-worker restoration, and stale pending state.
|
||||
- **Browser compatibility** — Uses Chrome's toolbar access request only when available, with a direct user-gesture permission fallback for Firefox and older Chromium browsers.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.1] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
+125
-32
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
addTabHostAccessRequest,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
normalizeTabId,
|
||||
removeTabHostAccessRequest
|
||||
removeTabHostAccessRequest,
|
||||
requestOriginPermission
|
||||
} from '../extension/host-access.js';
|
||||
|
||||
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
|
||||
@@ -17,6 +19,11 @@ assert.equal(normalizeTabId(undefined), null);
|
||||
assert.equal(normalizeTabId(''), null);
|
||||
assert.equal(normalizeTabId(0), null);
|
||||
assert.equal(normalizeTabId('42'), 42);
|
||||
assert.equal(normalizeTabId(true), null);
|
||||
assert.equal(normalizeTabId([42]), null);
|
||||
assert.equal(normalizeTabId('42.5'), null);
|
||||
assert.equal(normalizeTabId(' 42 '), 42);
|
||||
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
|
||||
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
|
||||
url: 'https://emby.example:8443/web/index.html',
|
||||
host: 'emby.example:8443',
|
||||
@@ -50,30 +57,41 @@ assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
|
||||
let requestedTabId = null;
|
||||
const requestChrome = {
|
||||
permissions: {
|
||||
addHostAccessRequest: async ({ tabId }) => { requestedTabId = tabId; }
|
||||
addHostAccessRequest: async request => { requestedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await addTabHostAccessRequest(requestChrome, 42), true);
|
||||
assert.equal(requestedTabId, 42);
|
||||
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
let removedTabId = null;
|
||||
const removeRequestChrome = {
|
||||
permissions: {
|
||||
removeHostAccessRequest: async ({ tabId }) => { removedTabId = tabId; }
|
||||
removeHostAccessRequest: async request => { removedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42), true);
|
||||
assert.equal(removedTabId, 42);
|
||||
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
|
||||
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
|
||||
const callbackPermissionChrome = {
|
||||
runtime: {},
|
||||
permissions: {
|
||||
request: (_request, callback) => { callback(true); }
|
||||
}
|
||||
};
|
||||
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
|
||||
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
|
||||
|
||||
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
|
||||
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
|
||||
|
||||
assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/,
|
||||
'SET_TARGET_TAB must await successful activation before acknowledging it');
|
||||
assert.match(background, /addTabHostAccessRequest\(chrome, tabId\)/,
|
||||
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
|
||||
'failed injection must register Chrome host-access request');
|
||||
assert.match(background, /retryPendingTarget\(\)/,
|
||||
'pending target must resume after the user grants access');
|
||||
@@ -87,11 +105,11 @@ assert.ok(
|
||||
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
|
||||
'a tab must not become current until its content script injection succeeds'
|
||||
);
|
||||
assert.match(background, /removeTabHostAccessRequest\(chrome, pendingTabId\)/,
|
||||
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
|
||||
'clearing a pending target must also clear Chrome toolbar access requests');
|
||||
assert.match(popup, /response\?\.status === 'host_permission_required'/,
|
||||
'popup must render the structured host-access failure');
|
||||
assert.match(popup, /chrome\.permissions\.request\(\{ origins: \[originPattern\] \}\)/,
|
||||
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
|
||||
'retry button must request withheld host access directly');
|
||||
assert.match(popupHtml, /id="siteAccessNotice"/,
|
||||
'popup must contain a persistent site-access notice');
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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 template = read('website/template.html');
|
||||
const foundationCss = read('website/styles/foundation.css');
|
||||
const supportCss = read('website/styles/support.css');
|
||||
const helpPage = read('website/site-access-help.html');
|
||||
const build = read('website/build.cjs');
|
||||
const localesDir = path.join(repoRoot, 'website', 'locales');
|
||||
|
||||
assert.equal((template.match(/class="site-access-banner"/g) || []).length, 1,
|
||||
'localized landing template must contain exactly one support banner');
|
||||
assert.match(template, /href="\/site-access-help\.html"/,
|
||||
'every compiled language must link to the English root help page');
|
||||
assert.match(template, /\{\{SUPPORT_BANNER_TEXT\}\}/);
|
||||
assert.match(template, /\{\{SUPPORT_BANNER_CTA\}\}/);
|
||||
assert.match(foundationCss, /\.site-access-banner\s*\{/,
|
||||
'landing bundle must style the support banner without deferred CSS');
|
||||
|
||||
for (const file of fs.readdirSync(localesDir).filter(file => file.endsWith('.json'))) {
|
||||
const locale = JSON.parse(fs.readFileSync(path.join(localesDir, file), 'utf8'));
|
||||
assert.ok(locale.SUPPORT_BANNER_TEXT?.trim(), `${file} is missing SUPPORT_BANNER_TEXT`);
|
||||
assert.ok(locale.SUPPORT_BANNER_CTA?.trim(), `${file} is missing SUPPORT_BANNER_CTA`);
|
||||
}
|
||||
|
||||
assert.match(helpPage, /<html lang="en"/);
|
||||
assert.match(helpPage, /rel="canonical" href="https:\/\/sync\.koalastuff\.net\/site-access-help\.html"/);
|
||||
assert.doesNotMatch(helpPage, /\{\{[A-Z0-9_-]+\}\}/,
|
||||
'English-only static help page must not contain unresolved template placeholders');
|
||||
assert.match(helpPage, /Access requested/,
|
||||
'help page must explain the new Chromium access-request UI');
|
||||
assert.match(helpPage, /Firefox/,
|
||||
'help page must include Firefox-specific recovery instructions');
|
||||
assert.doesNotMatch(helpPage, /Peers \(1\)|Separate issue/,
|
||||
'website-access help must not include unrelated relay troubleshooting');
|
||||
assert.match(helpPage, /video and audio stream never leave your browser/,
|
||||
'help page must explain the privacy boundary of the permission');
|
||||
assert.match(helpPage, /support\.google\.com\/chrome/);
|
||||
assert.match(helpPage, /support\.mozilla\.org/);
|
||||
assert.match(supportCss, /html\.theme-light \.support-card/,
|
||||
'support cards must define a readable light-theme surface');
|
||||
|
||||
assert.match(build, /\{ src: 'site-access-help\.html', dest: 'site-access-help\.html' \}/,
|
||||
'website build must copy the help page to the root output');
|
||||
assert.match(build, /'styles\/support\.css'/,
|
||||
'website build must include support page styles in style.min.css');
|
||||
assert.match(build, /https:\/\/sync\.koalastuff\.net\/site-access-help\.html/,
|
||||
'sitemap must include the English-only help page');
|
||||
|
||||
console.log('Site-access help page and localized landing banner are complete');
|
||||
@@ -29,7 +29,6 @@ const checks = [
|
||||
['background syntax', 'node', ['-c', 'extension/background.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.cjs']],
|
||||
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
|
||||
['website site-access help', 'node', ['scripts/test-site-access-help.mjs']],
|
||||
['website theme coverage', 'node', ['scripts/test-website-theme.mjs']],
|
||||
['lint', 'npm', ['run', 'lint']],
|
||||
['root production audit', 'npm', ['audit', '--omit=dev']],
|
||||
|
||||
Reference in New Issue
Block a user