mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 20:49:22 +00:00
merge main and harden canonical media recovery
This commit is contained in:
+1
-2
@@ -67,8 +67,7 @@ Useful focused checks from the repository root:
|
||||
node -c extension/background.js
|
||||
node -c extension/content.js
|
||||
node -c extension/popup.js
|
||||
node scripts/test-episode-utils.mjs
|
||||
node scripts/test-title-privacy.mjs
|
||||
npx vitest run extension/episode-utils.test.mjs extension/title-privacy.test.mjs
|
||||
node scripts/test-audio-settings.mjs
|
||||
node scripts/test-locales.cjs
|
||||
```
|
||||
|
||||
+95
-71
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
@@ -1052,15 +1052,19 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
||||
return true;
|
||||
}
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) {
|
||||
webJoinCoordinator.invalidate();
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
reconnectStartTime = null;
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
forceDisconnect();
|
||||
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
|
||||
// Stop room-specific polling before the content script itself is removed.
|
||||
// Every terminal room exit must pass through the exact target identity while
|
||||
// it is still available, regardless of who initiated the exit.
|
||||
clearEpisodeLobbyState();
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
clearChatActivity();
|
||||
@@ -1072,15 +1076,22 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
||||
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) await deactivateTargetTab(currentTabId);
|
||||
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
clearEpisodeLobbyState();
|
||||
await clearPendingTarget();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) {
|
||||
clearTimeout(forceSyncTimeout);
|
||||
forceSyncTimeout = null;
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
chatActivityTimeline: [],
|
||||
@@ -1091,22 +1102,36 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
currentTargetHasVideo: false,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0,
|
||||
episodeLobby: null,
|
||||
hcmDesynced: false
|
||||
hcmDesynced: false,
|
||||
reconnectFailed: false,
|
||||
reconnectAttempts: 0,
|
||||
reconnectStartTime: null
|
||||
}).catch(() => {});
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
forceDisconnect();
|
||||
addLog(reason, 'info');
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
await endRoomSession({ notifyServer: true, reason });
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (isConnecting) return;
|
||||
isConnecting = true;
|
||||
const startingGeneration = connectionGeneration;
|
||||
let attemptGeneration = startingGeneration;
|
||||
let handedOffToSocket = false;
|
||||
|
||||
let finalUrl = '';
|
||||
try {
|
||||
@@ -1234,7 +1259,7 @@ async function connect() {
|
||||
} else {
|
||||
awaitingRoomData = false;
|
||||
pendingRoomDataRoomId = null;
|
||||
flushEventQueue();
|
||||
flushEventQueue().catch(error => addLog(`Queue replay failed: ${error.message}`, 'warn'));
|
||||
}
|
||||
} else if (msg.startsWith('42')) {
|
||||
try {
|
||||
@@ -1295,6 +1320,7 @@ async function connect() {
|
||||
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
|
||||
addLog('WebSocket Error: Connection failed', logType);
|
||||
};
|
||||
handedOffToSocket = true;
|
||||
} catch (e) {
|
||||
throw new Error(`[Connection Error] ${e.message}`);
|
||||
}
|
||||
@@ -1309,6 +1335,8 @@ async function connect() {
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
} finally {
|
||||
if (!handedOffToSocket) isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2092,6 +2120,13 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
}
|
||||
case EVENTS.ERROR:
|
||||
isConnecting = false;
|
||||
const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED
|
||||
|| data.code === ERROR_CODES.PEER_TIMED_OUT
|
||||
|| data.message === 'Room closed'
|
||||
|| data.message === 'Removed from room after inactivity';
|
||||
if (currentRoom && terminalRoomError) {
|
||||
await endRoomSession({ reason: `Room session ended: ${data.message}` });
|
||||
}
|
||||
// If we get a server error before successfully joining a room,
|
||||
// clear persisted credentials as well, otherwise service-worker
|
||||
// restart would immediately retry the rejected room.
|
||||
@@ -2999,10 +3034,46 @@ function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs as a tiny all-frame beacon before the heavier monitor injection.
|
||||
* Chromium can reject an allFrames result wholesale when one unrelated ad
|
||||
* frame disappears mid-sweep, even though stable frames already executed the
|
||||
* function. Those stable frames announce their ids through the sender metadata,
|
||||
* letting the next step address them individually without webNavigation.
|
||||
*/
|
||||
async function announcePotentialMediaFrame() {
|
||||
let relevant = false;
|
||||
try {
|
||||
const identity = `${window.location.href} ${window.name || ''}`;
|
||||
relevant = !!document.querySelector('video, iframe, frame')
|
||||
|| /player|video|stream|watch|embed|media|xfp/i.test(identity);
|
||||
} catch { /* inaccessible or already-detached document */ }
|
||||
if (!relevant) return false;
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'MEDIA_FRAME_DISCOVERED' });
|
||||
} catch { /* extension context or document disappeared */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
async function injectMediaFrameMonitors(tabId, contentTarget) {
|
||||
// The sweep is best effort; the known frames are addressed individually so a
|
||||
// rejected sweep cannot leave the deep player frame without a monitor — and
|
||||
// therefore without any way to report itself later.
|
||||
try {
|
||||
const discoveries = await executeScriptWithTimeout({
|
||||
target: { tabId, allFrames: true },
|
||||
func: announcePotentialMediaFrame
|
||||
}, 750);
|
||||
for (const entry of discoveries || []) {
|
||||
if (entry?.result === true) rememberFrameId(tabId, entry.frameId);
|
||||
}
|
||||
} catch {
|
||||
// Stable frames still announce themselves if a disappearing ad frame
|
||||
// makes Chromium reject the aggregate allFrames result.
|
||||
}
|
||||
// Give those sender messages one task boundary to update the registry before
|
||||
// taking the snapshot used for individual monitor injections below.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const targets = [
|
||||
...listMediaFrameScriptTargets(tabId),
|
||||
...listKnownFrameIds(tabId)
|
||||
@@ -3684,6 +3755,14 @@ async function selectedMediaTargetMoved(tabId) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// A disappearing ad frame can make the parent-visibility handshake
|
||||
// inconclusive while still leaving one hidden mirror as the only video
|
||||
// candidate. Never rebuild toward an unconfirmed nested frame: its monitor
|
||||
// or a later clean probe will announce it again if it is genuinely visible.
|
||||
if (normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true) {
|
||||
refreshMediaFrameMonitors(tabId).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
if (currentTargetHasVideo !== true) return true;
|
||||
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|
||||
|| (typeof resolved.documentId === 'string'
|
||||
@@ -4137,6 +4216,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
const senderTabId = normalizeTabId(sender?.tab?.id);
|
||||
if (senderTabId !== null) rememberFrameId(senderTabId, sender?.frameId);
|
||||
if (message.type === 'MEDIA_FRAME_DISCOVERED') {
|
||||
sendResponse({ status: 'ok' });
|
||||
return;
|
||||
}
|
||||
const mediaLifecycleMessage = message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'
|
||||
|| message.type === 'MEDIA_FRAME_VISIBILITY'
|
||||
|| message.type === 'MEDIA_TARGET_REFRESH';
|
||||
@@ -4470,66 +4553,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
webJoinCoordinator.invalidate();
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
clearChatActivity();
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
|
||||
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
|
||||
updateBadgeStatus();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
await clearPendingTarget();
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
chatActivityTimeline: [],
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
currentTargetFrameId: 0,
|
||||
currentTargetDocumentId: null,
|
||||
currentTargetHasVideo: false,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
episodeLobby: null,
|
||||
expectedAcksCount: 0,
|
||||
hcmDesynced: false
|
||||
});
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
forceDisconnect();
|
||||
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CLEAR_LOGS') {
|
||||
logs = [];
|
||||
|
||||
@@ -8,8 +8,9 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'
|
||||
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
|
||||
|
||||
function functionBody(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}`);
|
||||
const end = source.indexOf(`function ${nextName}`, start + 1);
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start + 1);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return source.slice(start, end === -1 ? source.length : end);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CAPABILITIES, MAX_MEDIA_TIME } from './shared/constants.js';
|
||||
import { CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
|
||||
export function validateCanonicalMediaState(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
|
||||
@@ -39,12 +39,15 @@ describe('chat crypto', () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
let deriveCalls = 0;
|
||||
let releaseDerive;
|
||||
let markDeriveStarted;
|
||||
const deriveStarted = new Promise(resolve => { markDeriveStarted = resolve; });
|
||||
const delayedCrypto = {
|
||||
...webcrypto,
|
||||
subtle: {
|
||||
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
||||
deriveKey: async (...args) => {
|
||||
deriveCalls++;
|
||||
markDeriveStarted();
|
||||
await new Promise(resolve => { releaseDerive = resolve; });
|
||||
return webcrypto.subtle.deriveKey(...args);
|
||||
}
|
||||
@@ -52,7 +55,7 @@ describe('chat crypto', () => {
|
||||
};
|
||||
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
await Promise.resolve();
|
||||
await deriveStarted;
|
||||
expect(deriveCalls).toBe(1);
|
||||
clearChatKeyCache();
|
||||
releaseDerive();
|
||||
|
||||
@@ -660,7 +660,14 @@
|
||||
|| Number(style.opacity) === 0)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof current.checkVisibility === 'function'
|
||||
// `display: contents` deliberately gives the wrapper no box, so
|
||||
// Chromium reports the wrapper itself as not visible even while
|
||||
// its children are fully rendered. Crunchyroll's player layout
|
||||
// uses exactly that shape. The explicit CSS checks above still
|
||||
// reject genuinely hidden ancestors; skip only this boxless
|
||||
// wrapper case when walking up from the video.
|
||||
if (style?.display !== 'contents'
|
||||
&& typeof current.checkVisibility === 'function'
|
||||
&& !current.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractEpisodeId, sameEpisode } from './episode-utils.js';
|
||||
|
||||
describe('episode title matching', () => {
|
||||
it.each([
|
||||
['S01E01', 'S01E01'],
|
||||
['S1E1', 'S01E01'],
|
||||
['s01e01', 'S01E01'],
|
||||
['Season 1 Episode 2', 'S01E02'],
|
||||
['season 01 episode 02', 'S01E02'],
|
||||
['S01 - E01', 'S01E01'],
|
||||
['S01.E01', 'S01E01'],
|
||||
['S01/E01', 'S01E01'],
|
||||
['S01:E01', 'S01E01'],
|
||||
['S01,E01', 'S01E01'],
|
||||
['S01 E01', 'S01E01'],
|
||||
['Folge 5', 'EP005'],
|
||||
['Episode 12', 'EP012'],
|
||||
['Ep. 3', 'EP003'],
|
||||
['#42', 'EP042'],
|
||||
['S01E001', 'S01E001']
|
||||
])('extracts %s as %s', (title, expected) => {
|
||||
expect(extractEpisodeId(title)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([null, undefined, '', 123, 'Some Movie Title', 'Breaking Bad'])(
|
||||
'returns null for non-episode input %j',
|
||||
input => expect(extractEpisodeId(input)).toBeNull()
|
||||
);
|
||||
|
||||
it.each([
|
||||
['S01E01', 'S01E01'],
|
||||
['S01E01 - Pilot', 'S01E01'],
|
||||
['Folge 5', 'Episode 5'],
|
||||
['Episode 12', 'Ep. 12'],
|
||||
['#42', 'Folge 42'],
|
||||
[null, null],
|
||||
['', ''],
|
||||
['Some Movie', 'Some Movie']
|
||||
])('matches equivalent titles %j and %j', (left, right) => {
|
||||
expect(sameEpisode(left, right)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['S01E01', 'S01E02'],
|
||||
['S01E01', 'S02E01'],
|
||||
['Folge 1', 'Folge 2'],
|
||||
['Some Movie', 'Other Movie'],
|
||||
['S01E01', null],
|
||||
[null, 'Episode 5'],
|
||||
['S01E05', 'Episode 5'],
|
||||
['S01E01', 'EP001']
|
||||
])('rejects different titles %j and %j', (left, right) => {
|
||||
expect(sameEpisode(left, right)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
HOST_ACCESS_REQUIRED_STATUS,
|
||||
addTabHostAccessRequest,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
normalizeTabId,
|
||||
removeTabHostAccessRequest,
|
||||
requestOriginPermission
|
||||
} from './host-access.js';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
describe('host access helpers', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('normalizes only positive safe tab IDs', () => {
|
||||
expect(HOST_ACCESS_REQUIRED_STATUS).toBe('host_permission_required');
|
||||
for (const invalid of [null, undefined, '', 0, true, [42], '42.5', Number.MAX_SAFE_INTEGER + 1]) {
|
||||
expect(normalizeTabId(invalid)).toBeNull();
|
||||
}
|
||||
expect(normalizeTabId('42')).toBe(42);
|
||||
expect(normalizeTabId(' 42 ')).toBe(42);
|
||||
});
|
||||
|
||||
it('describes supported origins with Firefox-compatible localhost permissions', () => {
|
||||
expect(describeTabUrl('https://emby.example:8443/web/index.html')).toEqual({
|
||||
url: 'https://emby.example:8443/web/index.html',
|
||||
host: 'emby.example:8443',
|
||||
originPattern: 'https://emby.example:8443/*'
|
||||
});
|
||||
expect(describeTabUrl('http://localhost:8096/web/', { includePort: false })).toEqual({
|
||||
url: 'http://localhost:8096/web/',
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost/*'
|
||||
});
|
||||
expect(describeTabUrl('chrome://extensions/')).toBeNull();
|
||||
expect(describeTabUrl('not a url')).toBeNull();
|
||||
expect(describeTabUrl('file:///Users/koala/movie.mp4')).toEqual({
|
||||
url: 'file:///Users/koala/movie.mp4',
|
||||
host: 'local file',
|
||||
originPattern: 'file:///*'
|
||||
});
|
||||
});
|
||||
|
||||
it('checks the selected tab origin and preserves an unknown callback result', async () => {
|
||||
let containsRequest;
|
||||
const deniedChrome = {
|
||||
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
await expect(inspectTabHostAccess(deniedChrome, 42)).resolves.toMatchObject({
|
||||
granted: false,
|
||||
host: 'video.example',
|
||||
originPattern: 'https://video.example/*'
|
||||
});
|
||||
expect(containsRequest).toEqual({ origins: ['https://video.example/*'] });
|
||||
|
||||
const unknownChrome = {
|
||||
runtime: {},
|
||||
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
|
||||
permissions: { contains: (_request, callback) => callback(undefined) }
|
||||
};
|
||||
await expect(inspectTabHostAccess(unknownChrome, 42)).resolves.toMatchObject({ granted: null });
|
||||
});
|
||||
|
||||
it('uses Firefox host patterns without ports', async () => {
|
||||
let containsRequest;
|
||||
const chromeApi = {
|
||||
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
|
||||
tabs: {
|
||||
get: async tabId => ({
|
||||
id: tabId,
|
||||
url: 'http://localhost:8096/web/',
|
||||
pendingUrl: 'https://different.example/loading'
|
||||
})
|
||||
},
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
await expect(inspectTabHostAccess(chromeApi, 42)).resolves.toMatchObject({
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost/*'
|
||||
});
|
||||
expect(containsRequest).toEqual({ origins: ['http://localhost/*'] });
|
||||
});
|
||||
|
||||
it('adds, removes, and requests permissions through promise and callback APIs', async () => {
|
||||
let added;
|
||||
expect(await addTabHostAccessRequest({
|
||||
permissions: { addHostAccessRequest: async request => { added = request; } }
|
||||
}, 42, 'https://video.example/*')).toBe(true);
|
||||
expect(added).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
|
||||
expect(await addTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
|
||||
|
||||
let removed;
|
||||
expect(await removeTabHostAccessRequest({
|
||||
permissions: { removeHostAccessRequest: async request => { removed = request; } }
|
||||
}, 42, 'https://video.example/*')).toBe(true);
|
||||
expect(removed).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
|
||||
expect(await removeTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
|
||||
|
||||
const callbackChrome = {
|
||||
runtime: {},
|
||||
permissions: { request: (_request, callback) => callback(true) }
|
||||
};
|
||||
await expect(requestOriginPermission(callbackChrome, 'https://video.example/*')).resolves.toBe(true);
|
||||
await expect(requestOriginPermission({ permissions: {} }, 'https://video.example/*')).resolves.toBeNull();
|
||||
await expect(requestOriginPermission(callbackChrome, '')).resolves.toBeNull();
|
||||
await expect(requestOriginPermission({
|
||||
permissions: { request: async () => { throw new Error('denied'); } }
|
||||
}, 'https://video.example/*')).resolves.toBe(false);
|
||||
await expect(addTabHostAccessRequest({
|
||||
permissions: { addHostAccessRequest: async () => { throw new Error('denied'); } }
|
||||
}, 42)).resolves.toBe(false);
|
||||
await expect(removeTabHostAccessRequest({
|
||||
permissions: { removeHostAccessRequest: async () => { throw new Error('denied'); } }
|
||||
}, 42)).resolves.toBe(false);
|
||||
expect(isHostAccessError(new Error('Missing host permission for the tab'))).toBe(true);
|
||||
expect(isHostAccessError(new Error('No tab with id: 42'))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats permission inspection errors and timeouts as advisory unknowns', async () => {
|
||||
const base = {
|
||||
tabs: { get: async () => ({ url: 'https://video.example/watch' }) }
|
||||
};
|
||||
await expect(inspectTabHostAccess({
|
||||
...base,
|
||||
permissions: { contains: async () => { throw new Error('permission API failed'); } }
|
||||
}, 42)).resolves.toMatchObject({ granted: null });
|
||||
|
||||
await expect(inspectTabHostAccess({
|
||||
...base,
|
||||
runtime: { lastError: { message: 'permission callback failed' } },
|
||||
permissions: { contains: (_request, callback) => callback(false) }
|
||||
}, 42)).resolves.toMatchObject({ granted: null });
|
||||
|
||||
vi.useFakeTimers();
|
||||
const pending = inspectTabHostAccess({
|
||||
...base,
|
||||
permissions: { contains: () => undefined }
|
||||
}, 42);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(pending).resolves.toMatchObject({ granted: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('host access recovery contracts', () => {
|
||||
it('keeps activation, permission recovery, and target identity guarded', () => {
|
||||
const background = fs.readFileSync(path.join(repoRoot, 'extension/background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
|
||||
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
|
||||
const tabManager = fs.readFileSync(path.join(repoRoot, 'extension/modules/tab-manager.js'), 'utf8');
|
||||
|
||||
expect(background).toMatch(/await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/);
|
||||
expect(background).toMatch(/addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/);
|
||||
expect(background).toMatch(/retryPendingTarget\(\)/);
|
||||
expect(background).toMatch(/activationGeneration !== targetActivationGeneration/);
|
||||
expect(background).toMatch(/pendingTargetRequestId/);
|
||||
expect(background).toMatch(/addedOrigins\.includes\(pending\.originPattern\)/);
|
||||
expect(background).toMatch(/isCurrentTargetIdentity\(tabId, targetGeneration\)/);
|
||||
expect(background).toMatch(/message\.expectedTabId/);
|
||||
expect(background).toMatch(/completeForceSyncBeforeTargetChange\(selectedTabId\)/);
|
||||
expect(background).toMatch(/FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/);
|
||||
expect(background).toMatch(/removeTabHostAccessRequest\([\s\S]*pendingTabId/);
|
||||
|
||||
const activationBody = background.slice(
|
||||
background.indexOf('async function activateTargetTab'),
|
||||
background.indexOf('async function retryPendingTarget')
|
||||
);
|
||||
expect(activationBody.indexOf('await injectContentScript')).toBeLessThan(
|
||||
activationBody.indexOf('currentTabId = selectedTabId')
|
||||
);
|
||||
expect(popup).toMatch(/response\?\.status === 'host_permission_required'/);
|
||||
expect(popup).toMatch(/requestOriginPermission\(chrome, requestedOriginPattern\)/);
|
||||
expect(popup).toMatch(/expectedCurrentTabId: tabId/);
|
||||
expect(popup).toMatch(/expectedTabId: tabId/);
|
||||
expect(tabManager).not.toMatch(/injectContentScript/);
|
||||
expect((background.match(/tabs\.onRemoved\.addListener/g) || []).length
|
||||
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length).toBe(1);
|
||||
expect(popupHtml).toMatch(/id="siteAccessNotice"/);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_appName__",
|
||||
"short_name": "KoalaSync",
|
||||
"version": "3.1.3",
|
||||
"version": "3.1.5",
|
||||
"description": "__MSG_appDesc__",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -467,6 +467,7 @@ function contentTarget(tabId, selected, discoveredFrameIds = null) {
|
||||
documentId,
|
||||
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
||||
hasVideo: !!selected?.result?.bestVideo,
|
||||
visibilityConfirmed: selected?.result?.parentFrameVisible === true,
|
||||
scriptTarget: documentId
|
||||
? { tabId, documentIds: [documentId] }
|
||||
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
||||
|
||||
@@ -184,6 +184,7 @@ describe('cross-origin media-frame targeting', () => {
|
||||
documentId: 'document-8',
|
||||
frameUrl: 'https://player-8.example/embed',
|
||||
hasVideo: true,
|
||||
visibilityConfirmed: true,
|
||||
// Reported back so the caller can address these frames directly when
|
||||
// a later all-frames sweep is rejected wholesale.
|
||||
discoveredFrameIds: [0, 8],
|
||||
@@ -242,6 +243,7 @@ describe('cross-origin media-frame targeting', () => {
|
||||
documentId: null,
|
||||
frameUrl: null,
|
||||
hasVideo: false,
|
||||
visibilityConfirmed: false,
|
||||
discoveredFrameIds: [0, 6],
|
||||
scriptTarget: { tabId: 42 }
|
||||
});
|
||||
@@ -582,6 +584,7 @@ describe('embedded player access diagnosis', () => {
|
||||
documentId: null,
|
||||
frameUrl: null,
|
||||
hasVideo: false,
|
||||
visibilityConfirmed: false,
|
||||
discoveredFrameIds: [0],
|
||||
scriptTarget: { tabId: 42 }
|
||||
});
|
||||
|
||||
@@ -35,10 +35,14 @@ describe('offline media intent background integration', () => {
|
||||
const roomDataStart = backgroundSource.indexOf('case EVENTS.ROOM_DATA:');
|
||||
const roomDataEnd = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:', roomDataStart);
|
||||
const roomData = backgroundSource.slice(roomDataStart, roomDataEnd);
|
||||
expect(roomData.indexOf('applyQueuedRoomPolicy(data.roomId'))
|
||||
.toBeLessThan(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'));
|
||||
expect(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'))
|
||||
.toBeLessThan(roomData.indexOf('flushEventQueue(replaySettings)'));
|
||||
const policyIndex = roomData.indexOf('applyQueuedRoomPolicy(data.roomId');
|
||||
const canonicalIndex = roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
|
||||
const flushIndex = roomData.indexOf('flushEventQueue(replaySettings)');
|
||||
expect(policyIndex).toBeGreaterThan(-1);
|
||||
expect(canonicalIndex).toBeGreaterThan(-1);
|
||||
expect(flushIndex).toBeGreaterThan(-1);
|
||||
expect(policyIndex).toBeLessThan(canonicalIndex);
|
||||
expect(canonicalIndex).toBeLessThan(flushIndex);
|
||||
expect(roomData).toContain('activeLobby: !!episodeLobby');
|
||||
expect(roomData).toContain('desynced: hcmDesynced');
|
||||
expect(roomData).toContain('if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby)');
|
||||
@@ -53,7 +57,8 @@ describe('offline media intent background integration', () => {
|
||||
backgroundSource.indexOf("message.type === 'LEAVE_ROOM'"),
|
||||
backgroundSource.indexOf("message.type === 'CLEAR_LOGS'")
|
||||
);
|
||||
expect(leaveHandler).toContain('forceDisconnect()');
|
||||
expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })");
|
||||
expect(functionBody('endRoomSession', 'leaveRoomAfterIdleGrace')).toContain('forceDisconnect()');
|
||||
const retryHandler = backgroundSource.slice(
|
||||
backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"),
|
||||
backgroundSource.indexOf("message.type === 'GET_STATUS'")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, MAX_MEDIA_TIME } from './shared/constants.js';
|
||||
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
|
||||
export const MEDIA_INTENT_KIND = 'media-intent';
|
||||
export const MAX_LOGICAL_QUEUE_SIZE = 50;
|
||||
@@ -245,6 +245,7 @@ export function normalizePersistedEventQueue(value, roomId, maxEntries = MAX_LOG
|
||||
if (intentEntry) {
|
||||
intentEntry = repairIntentSequences(intentEntry, maximumSequence);
|
||||
normalized.push(intentEntry);
|
||||
normalized = trimQueue(normalized, maxEntries).queue;
|
||||
maximumSequence = Math.max(maximumSequence, maxQueuedSequence([intentEntry]));
|
||||
}
|
||||
continue;
|
||||
@@ -464,11 +465,18 @@ export async function drainQueuedBatch(queue, {
|
||||
droppedStaleIntents++;
|
||||
continue;
|
||||
}
|
||||
const frames = isQueuedMediaIntent(entry)
|
||||
? materializeMediaIntent(entry)
|
||||
: [{ event: entry.event, data: entry.data }];
|
||||
const deliveryEntries = entry?.event === EVENTS.FORCE_SYNC_PREPARE
|
||||
&& remaining[1]?.event === EVENTS.FORCE_SYNC_EXECUTE
|
||||
? remaining.slice(0, 2)
|
||||
: [entry];
|
||||
const frames = deliveryEntries.flatMap(deliveryEntry => {
|
||||
const deliveryFrames = isQueuedMediaIntent(deliveryEntry)
|
||||
? materializeMediaIntent(deliveryEntry)
|
||||
: [{ event: deliveryEntry.event, data: deliveryEntry.data }];
|
||||
return deliveryFrames.map(frame => ({ frame, entry: deliveryEntry }));
|
||||
});
|
||||
if (frames.length === 0) {
|
||||
remaining.shift();
|
||||
remaining.splice(0, deliveryEntries.length);
|
||||
continue;
|
||||
}
|
||||
if (sentWireEvents > 0 && sentWireEvents + frames.length > maxWireEvents) {
|
||||
@@ -479,8 +487,8 @@ export async function drainQueuedBatch(queue, {
|
||||
}
|
||||
|
||||
let sentEntryFrames = 0;
|
||||
for (const frame of frames) {
|
||||
if (!await sendFrame(frame, entry)) {
|
||||
for (const { frame, entry: deliveryEntry } of frames) {
|
||||
if (!await sendFrame(frame, deliveryEntry)) {
|
||||
return {
|
||||
queue: remaining,
|
||||
sentWireEvents: sentWireEvents + sentEntryFrames,
|
||||
@@ -491,7 +499,7 @@ export async function drainQueuedBatch(queue, {
|
||||
sentEntryFrames++;
|
||||
}
|
||||
sentWireEvents += sentEntryFrames;
|
||||
remaining.shift();
|
||||
remaining.splice(0, deliveryEntries.length);
|
||||
if (sentWireEvents >= maxWireEvents) {
|
||||
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EVENTS, MAX_MEDIA_TIME } from './shared/constants.js';
|
||||
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
import { canonicalMediaStateFromRoomData } from './canonical-media-state.js';
|
||||
import {
|
||||
discardQueuedMediaIntents,
|
||||
@@ -159,6 +159,26 @@ describe('offline media intent coalescing', () => {
|
||||
expect(maxQueuedSequence(restored)).toBe(4);
|
||||
});
|
||||
|
||||
it('enforces the cap while restoring consecutive persisted media intents', () => {
|
||||
const persisted = [10, 20, 30].map((currentTime, index) => ({
|
||||
kind: 'media-intent',
|
||||
roomId,
|
||||
intent: {
|
||||
playbackState: 'paused',
|
||||
currentTime,
|
||||
latestEvent: EVENTS.PAUSE,
|
||||
previousSeq: null,
|
||||
latestSeq: index + 1,
|
||||
actionTimestamp: index + 1,
|
||||
mediaTitle: null,
|
||||
sourceEventCount: 1
|
||||
}
|
||||
}));
|
||||
const restored = normalizePersistedEventQueue(persisted, roomId, 2);
|
||||
expect(restored).toHaveLength(2);
|
||||
expect(restored.map(entry => entry.intent.currentTime)).toEqual([20, 30]);
|
||||
});
|
||||
|
||||
it('drops room-scoped barriers from another room and unknown persisted events', () => {
|
||||
const restored = normalizePersistedEventQueue([
|
||||
{ kind: 'event', roomId: 'room-b', event: EVENTS.FORCE_SYNC_EXECUTE, data: { seq: 1 } },
|
||||
@@ -336,6 +356,39 @@ describe('offline media intent drain', () => {
|
||||
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_EXECUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps adjacent Force Sync PREPARE and EXECUTE in the same replay batch', async () => {
|
||||
let queue = [];
|
||||
for (let index = 0; index < 9; index++) {
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: index + 1 }, { roomId }).queue;
|
||||
}
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 10 }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 11 }, { roomId }).queue;
|
||||
const sent = [];
|
||||
const result = await drainQueuedBatch(queue, {
|
||||
roomId,
|
||||
maxWireEvents: 10,
|
||||
sendFrame: async frame => { sent.push(frame.event); return true; }
|
||||
});
|
||||
expect(sent).toEqual(Array(9).fill(EVENTS.EPISODE_READY));
|
||||
expect(result.queue.map(entry => entry.event)).toEqual([
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains the full Force Sync transaction if EXECUTE replay fails', async () => {
|
||||
let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue;
|
||||
const result = await drainQueuedBatch(queue, {
|
||||
roomId,
|
||||
maxWireEvents: 10,
|
||||
sendFrame: async frame => frame.event !== EVENTS.FORCE_SYNC_EXECUTE
|
||||
});
|
||||
expect(result.status).toBe('send_failed');
|
||||
expect(result.sentWireEvents).toBe(1);
|
||||
expect(result.queue).toEqual(queue);
|
||||
});
|
||||
|
||||
it('reports logical and actual-wire queue sizes separately', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { seq: 3 }, { roomId }).queue;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.html'), 'utf8');
|
||||
|
||||
describe('popup layout containment', () => {
|
||||
it('prevents dynamic descendants from changing the 360px popup width', () => {
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*min-width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*max-width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*overflow-x:\s*hidden;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*max-width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*contain:\s*inline-size;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*overflow-x:\s*hidden;/s);
|
||||
});
|
||||
});
|
||||
+25
-11
@@ -180,11 +180,25 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Defensive popup boundary: descendants may be populated dynamically
|
||||
after Chrome has measured the action popup. Keep their intrinsic
|
||||
width from resizing the popup window while preserving normal vertical
|
||||
layout and scrolling inside the established 360px surface. */
|
||||
html {
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 360px;
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
contain: inline-size;
|
||||
overflow-x: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
@@ -1413,16 +1427,16 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
|
||||
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
|
||||
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
|
||||
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
|
||||
<button id="devToolsTabBtn" class="tab-btn" data-tab="tab-devtools" style="display:none;">Dev</button>
|
||||
<div class="tabs" role="tablist" aria-label="KoalaSync sections">
|
||||
<button id="tab-room-button" class="tab-btn active" role="tab" aria-selected="true" aria-controls="tab-room" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
|
||||
<button id="tab-sync-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-sync" tabindex="-1" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
|
||||
<button id="tab-settings-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-settings" tabindex="-1" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
|
||||
<button id="tab-dev-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-dev" tabindex="-1" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
|
||||
<button id="devToolsTabBtn" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-devtools" tabindex="-1" data-tab="tab-devtools" style="display:none;">Dev</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Tab -->
|
||||
<div id="tab-room" class="tab-content active">
|
||||
<div id="tab-room" class="tab-content active" role="tabpanel" aria-labelledby="tab-room-button">
|
||||
|
||||
<!-- JOIN SECTION: Visible when not in a room -->
|
||||
<div id="section-join">
|
||||
@@ -1510,7 +1524,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Sync Tab -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<div id="tab-sync" class="tab-content" role="tabpanel" aria-labelledby="tab-sync-button" aria-hidden="true">
|
||||
<!-- SYNC ACTIVE: Visible when in a room -->
|
||||
<div id="sync-active">
|
||||
<div class="form-group" style="position: relative;">
|
||||
@@ -1576,7 +1590,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<div id="tab-settings" class="tab-content" role="tabpanel" aria-labelledby="tab-settings-button" aria-hidden="true">
|
||||
<details class="form-group" name="settings-accordion" open>
|
||||
<summary title="Change your username, theme, and language." data-i18n="LABEL_SETTINGS_GROUP_PROFILE" data-i18n-title="LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP">Profile & Appearance</summary>
|
||||
<div class="details-content">
|
||||
@@ -1813,7 +1827,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Dev Tab -->
|
||||
<div id="tab-dev" class="tab-content">
|
||||
<div id="tab-dev" class="tab-content" role="tabpanel" aria-labelledby="tab-dev-button" aria-hidden="true">
|
||||
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
|
||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
||||
<span id="connDot" class="status-dot status-offline"></span>
|
||||
@@ -1845,7 +1859,7 @@
|
||||
<div id="logList"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-devtools" class="tab-content">
|
||||
<div id="tab-devtools" class="tab-content" role="tabpanel" aria-labelledby="devToolsTabBtn" aria-hidden="true">
|
||||
<label>Remote Seek</label>
|
||||
<div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;">
|
||||
<button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button>
|
||||
|
||||
+26
-2
@@ -1791,12 +1791,22 @@ elements.serverUrl.addEventListener('change', () => {
|
||||
|
||||
elements.tabs.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
elements.tabs.forEach(b => b.classList.remove('active'));
|
||||
elements.contents.forEach(c => c.classList.remove('active'));
|
||||
elements.tabs.forEach(b => {
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-selected', 'false');
|
||||
b.tabIndex = -1;
|
||||
});
|
||||
elements.contents.forEach(c => {
|
||||
c.classList.remove('active');
|
||||
c.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-selected', 'true');
|
||||
btn.tabIndex = 0;
|
||||
|
||||
const targetContent = document.getElementById(btn.dataset.tab);
|
||||
targetContent.classList.add('active');
|
||||
targetContent.removeAttribute('aria-hidden');
|
||||
|
||||
targetContent.classList.remove('tab-active-animate');
|
||||
void targetContent.offsetWidth; // Force reflow to restart animation
|
||||
@@ -1808,6 +1818,20 @@ elements.tabs.forEach(btn => {
|
||||
|
||||
chrome.storage.local.set({ activeTab: btn.dataset.tab });
|
||||
});
|
||||
|
||||
btn.addEventListener('keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
const visibleTabs = [...elements.tabs].filter(tab => window.getComputedStyle(tab).display !== 'none');
|
||||
const currentIndex = visibleTabs.indexOf(btn);
|
||||
const nextIndex = event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? visibleTabs.length - 1
|
||||
: (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + visibleTabs.length) % visibleTabs.length;
|
||||
event.preventDefault();
|
||||
visibleTabs[nextIndex].focus();
|
||||
visibleTabs[nextIndex].click();
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
|
||||
@@ -9,6 +9,8 @@ const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'ut
|
||||
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
|
||||
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
|
||||
const sharedConstantsSource = fs.readFileSync(path.join(extensionDir, '..', 'shared', 'constants.js'), 'utf8');
|
||||
const serverSource = fs.readFileSync(path.join(extensionDir, '..', 'server', 'index.js'), 'utf8');
|
||||
|
||||
describe('target tab lifecycle', () => {
|
||||
it('injects playback and chat scripts only into the explicitly selected tab', () => {
|
||||
@@ -96,6 +98,37 @@ describe('target tab lifecycle', () => {
|
||||
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
|
||||
});
|
||||
|
||||
it('routes every terminal room exit through the full target unhook', () => {
|
||||
const teardownStart = backgroundSource.indexOf('async function endRoomSession');
|
||||
const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
|
||||
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
|
||||
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
|
||||
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
|
||||
.toBeLessThan(teardownSource.indexOf('currentTabId = null'));
|
||||
expect(teardownSource).toContain('await clearPendingTarget()');
|
||||
expect(teardownSource).toContain('forceDisconnect()');
|
||||
|
||||
expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
|
||||
expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");
|
||||
expect(backgroundSource).toContain('data.code === ERROR_CODES.ROOM_CLOSED');
|
||||
expect(backgroundSource).toContain('data.code === ERROR_CODES.PEER_TIMED_OUT');
|
||||
expect(backgroundSource).toContain("data.message === 'Room closed'");
|
||||
expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
|
||||
expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
|
||||
|
||||
expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
|
||||
expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
|
||||
expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
|
||||
expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
|
||||
expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
|
||||
});
|
||||
|
||||
it('does not promote a nested media target without confirmed parent visibility', () => {
|
||||
expect(backgroundSource).toContain(
|
||||
'normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true'
|
||||
);
|
||||
});
|
||||
|
||||
it('removes monitors injected by a superseded cross-tab activation', () => {
|
||||
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
|
||||
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
|
||||
@@ -105,6 +138,9 @@ describe('target tab lifecycle', () => {
|
||||
|
||||
it('uses all-frame probing for cross-origin targets without navigation permissions', () => {
|
||||
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
|
||||
expect(backgroundSource).toContain('async function announcePotentialMediaFrame()');
|
||||
expect(backgroundSource).toContain("{ type: 'MEDIA_FRAME_DISCOVERED' }");
|
||||
expect(backgroundSource).toContain('func: announcePotentialMediaFrame');
|
||||
// Monitors must reach the frames we know about, not only whatever the
|
||||
// all-frames sweep happens to accept — both on the way in and out.
|
||||
expect(backgroundSource.match(/\.\.\.listMediaFrameScriptTargets\(tabId\),/g)?.length).toBe(2);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
TITLE_PRIVACY_MODES,
|
||||
applyTitlePrivacyToPayload,
|
||||
normalizeSendTabTitle,
|
||||
normalizeTabTitle,
|
||||
normalizeTitlePrivacyMode,
|
||||
sanitizeSharedTitle,
|
||||
sanitizeTabTitle
|
||||
} from './title-privacy.js';
|
||||
|
||||
describe('title privacy', () => {
|
||||
it('normalizes settings and tab notification prefixes', () => {
|
||||
expect(normalizeTitlePrivacyMode(undefined)).toBe(TITLE_PRIVACY_MODES.FULL);
|
||||
expect(normalizeTitlePrivacyMode('unknown')).toBe(TITLE_PRIVACY_MODES.FULL);
|
||||
expect(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN)).toBe(TITLE_PRIVACY_MODES.HIDDEN);
|
||||
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL)).toBe(true);
|
||||
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE)).toBe(false);
|
||||
expect(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN)).toBe(true);
|
||||
expect(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL)).toBe(false);
|
||||
expect(normalizeTabTitle('(12) Testvideo - YouTube')).toBe('Testvideo - YouTube');
|
||||
expect(normalizeTabTitle('[999+] Testvideo - YouTube')).toBe('Testvideo - YouTube');
|
||||
expect(normalizeTabTitle('(500) Days of Summer')).toBe('Days of Summer');
|
||||
for (const title of ['[7] Testvideo', '(99+) Testvideo', '(999+) Testvideo', '(101) Testvideo', '[101] Testvideo']) {
|
||||
expect(normalizeTabTitle(title)).toBe('Testvideo');
|
||||
}
|
||||
expect(normalizeTabTitle(null)).toBeNull();
|
||||
expect(normalizeTabTitle(' ')).toBeNull();
|
||||
expect(sanitizeTabTitle('', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps tab-title and media-title privacy independent', () => {
|
||||
expect(sanitizeTabTitle('(12) Private Tab', true)).toBe('Private Tab');
|
||||
expect(sanitizeTabTitle('Private Tab', false)).toBeNull();
|
||||
expect(sanitizeSharedTitle('Example Movie', 'full')).toBe('Example Movie');
|
||||
expect(sanitizeSharedTitle('', 'full')).toBeNull();
|
||||
expect(sanitizeSharedTitle(null, 'full')).toBeNull();
|
||||
expect(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode')).toBe('S01E04');
|
||||
expect(sanitizeSharedTitle('Folge 7 - Private Server', 'episode')).toBe('EP007');
|
||||
expect(sanitizeSharedTitle('Example Movie', 'episode')).toBeNull();
|
||||
expect(sanitizeSharedTitle('Show Name - S01E04', 'hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites only present media keys without mutating the input', () => {
|
||||
const input = {
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: 'Private Media',
|
||||
expectedTitle: 'S01E04',
|
||||
title: 'S01E04',
|
||||
currentTime: 42
|
||||
};
|
||||
expect(applyTitlePrivacyToPayload(input, 'hidden')).toEqual({
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: null,
|
||||
expectedTitle: null,
|
||||
title: null,
|
||||
currentTime: 42
|
||||
});
|
||||
expect(input.mediaTitle).toBe('Private Media');
|
||||
expect(applyTitlePrivacyToPayload({ tabTitle: 'Private Tab', status: 'heartbeat' }, 'episode')).toEqual({
|
||||
tabTitle: 'Private Tab',
|
||||
status: 'heartbeat'
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user