fix: harden room teardown and popup containment

This commit is contained in:
KoalaDev
2026-08-25 01:10:41 +02:00
parent 7286a6db3d
commit 204f1d0a8f
18 changed files with 238 additions and 86 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.1.4-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.1.5-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
+50 -69
View File
@@ -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';
@@ -988,15 +988,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;
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
@@ -1007,15 +1011,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: [],
@@ -1026,17 +1037,30 @@ 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;
@@ -1739,6 +1763,13 @@ async function handleServerEvent(event, data) {
}
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.
@@ -3340,6 +3371,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'
@@ -4118,65 +4157,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
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;
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 = [];
+4 -1
View File
@@ -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();
+1 -1
View File
@@ -3,7 +3,7 @@
"default_locale": "en",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "3.1.4",
"version": "3.1.5",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
+1
View File
@@ -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] })
+3
View File
@@ -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 }
});
+20
View File
@@ -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);
});
});
+14
View File
@@ -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;
+33
View File
@@ -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*\}\)/);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "koalasync",
"version": "3.1.4",
"version": "3.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "3.1.4",
"version": "3.1.5",
"devDependencies": {
"@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "3.1.4",
"version": "3.1.5",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
+40
View File
@@ -67,6 +67,25 @@ try {
close();
resetConnectionRate();
// --- Stale peer reaper: terminal timeout + clean rejoin ---
const staleClient = await c();
const staleRoomId = 'stale-'+Date.now();
await j(staleClient, staleRoomId, 'stale-peer');
staleClient._m.length = 0;
const staleRoom = mod.rooms.get(staleRoomId);
staleRoom.peerData.values().next().value.lastSeen = 1;
mod.cleanupInactiveRooms(Date.now());
const [staleEvent, staleData] = await a(staleClient);
assert.equal(staleEvent, 'error');
assert.equal(staleData.code, 'peer_timed_out');
assert.equal(staleData.message, 'Removed from room after inactivity');
assert.equal(mod.rooms.has(staleRoomId), false, 'stale peer room is deleted');
staleClient._m.length = 0;
await j(staleClient, staleRoomId, 'stale-peer');
assert.equal(mod.rooms.has(staleRoomId), true, 'stale peer can rejoin cleanly');
close();
resetConnectionRate();
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
const capClient = await c();
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
@@ -80,6 +99,27 @@ try {
close();
resetConnectionRate();
// --- Terminal room timeout: coded error + complete membership cleanup ---
const timeoutClient = await c();
const timeoutRoomId = 'timeout-'+Date.now();
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
timeoutClient._m.length = 0;
mod.rooms.get(timeoutRoomId).lastActivity = 0;
mod.cleanupInactiveRooms(Date.now());
const [timeoutEvent, timeoutData] = await a(timeoutClient);
assert.equal(timeoutEvent, 'error');
assert.equal(timeoutData.code, 'room_closed');
assert.equal(timeoutData.message, 'Room closed');
assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
timeoutClient._m.length = 0;
// The same connected socket must be able to join that room again. This
// proves timeout cleanup removed its stale socketToRoom membership.
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
assert.equal(mod.rooms.has(timeoutRoomId), true, 'timed-out peer can rejoin cleanly');
close();
resetConnectionRate();
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c();
+24 -6
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
buildHealthPayload,
@@ -922,8 +922,7 @@ io.on('connection', (socket) => {
});
// Active Room & Dead Peer Cleanup (Every 2m)
const roomCleanupInterval = setInterval(() => {
const now = Date.now();
export function cleanupInactiveRooms(now = Date.now()) {
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
@@ -942,7 +941,13 @@ const roomCleanupInterval = setInterval(() => {
}
for (const sid of staleSids) {
const deadSocket = io.sockets?.sockets?.get(sid);
if (deadSocket) deadSocket.leave(roomId);
if (deadSocket) {
deadSocket.emit(EVENTS.ERROR, {
code: ERROR_CODES.PEER_TIMED_OUT,
message: 'Removed from room after inactivity'
});
deadSocket.leave(roomId);
}
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
try {
removePeerFromRoom(sid, roomId, 'reaper');
@@ -954,12 +959,25 @@ const roomCleanupInterval = setInterval(() => {
// 2. Prune empty or inactive rooms
const currentRoom = rooms.get(roomId);
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
io.to(roomId).emit(EVENTS.ERROR, {
code: ERROR_CODES.ROOM_CLOSED,
message: 'Room closed'
});
// A terminal room timeout is a real leave for every member. Clear
// the same socket/peer indexes as an explicit leave so a later join
// cannot be mistaken for the stale membership.
for (const sid of Array.from(currentRoom.peers)) {
const memberSocket = io.sockets?.sockets?.get(sid);
if (memberSocket) memberSocket.leave(roomId);
removePeerFromRoom(sid, roomId, 'room-timeout');
}
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
}
}
}, 2 * 60 * 1000);
}
const roomCleanupInterval = setInterval(cleanupInactiveRooms, 2 * 60 * 1000);
export function startServer(port = PORT, host) {
if (httpServer.listening) return Promise.resolve(httpServer);
+9 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "3.1.4";
export const APP_VERSION = "3.1.5";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
@@ -65,6 +65,14 @@ export const EVENTS = {
PONG: "pong" // server responds with same { t } for client RTT calculation
};
// Stable server error identifiers. Clients must branch on these codes instead
// of localized or user-facing message text whenever the error changes session
// state.
export const ERROR_CODES = {
ROOM_CLOSED: 'room_closed',
PEER_TIMED_OUT: 'peer_timed_out'
};
// Room control modes (Host Control Mode feature).
// NOTE: content.js does not import this module — it uses the string literals
// 'everyone' / 'host-only' directly. Keep these values in sync there.
+31
View File
@@ -27,3 +27,34 @@ test('popup exposes names and keyboard access for every visible control', async
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('#tab-settings')).toBeVisible();
});
test('popup root remains 360px when a dynamic child overflows', async ({ context, extensionId }) => {
const page = await openPopup(context, extensionId, { openEditor: false });
const geometry = await page.evaluate(() => {
const probe = document.createElement('div');
probe.id = 'popup-overflow-probe';
probe.style.width = '1200px';
probe.style.height = '1px';
document.body.appendChild(probe);
const htmlStyle = window.getComputedStyle(document.documentElement);
const bodyStyle = window.getComputedStyle(document.body);
return {
htmlWidth: document.documentElement.getBoundingClientRect().width,
bodyWidth: document.body.getBoundingClientRect().width,
htmlOverflowX: htmlStyle.overflowX,
bodyOverflowX: bodyStyle.overflowX,
bodyContain: bodyStyle.contain,
probeWidth: probe.getBoundingClientRect().width
};
});
expect(geometry).toEqual({
htmlWidth: 360,
bodyWidth: 360,
htmlOverflowX: 'hidden',
bodyOverflowX: 'hidden',
bodyContain: 'inline-size',
probeWidth: 1200
});
});
+1 -1
View File
@@ -87,7 +87,7 @@ Compatibility depends on each website's player implementation and can change whe
## Technical information
- Current website release: 3.1.4
- Current website release: 3.1.5
- License: MIT
- Extension runtime: dependency-free browser extension code
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
+1 -1
View File
@@ -116,7 +116,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "3.1.4",
"softwareVersion": "3.1.5",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "3.1.4",
"date": "2026-08-21T10:33:30Z"
"version": "3.1.5",
"date": "2026-08-24T21:56:28Z"
}