mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6276ee134c | |||
| d847389eef | |||
| aa84b63c77 | |||
| bd60a14754 | |||
| a1bdcf4325 | |||
| b846803062 | |||
| 4bc7ad365d | |||
| 68b2205b0d | |||
| b450845522 | |||
| 27023ea58e | |||
| 1a7d23ce93 | |||
| d2c8ca1c1e | |||
| 1c404a7f11 | |||
| e2bba04efd | |||
| c7b29c277c |
@@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
# Runs the full verification suite (lint, unit/integration tests, production
|
||||
# audits, extension + website build) on every push to main and every PR, so a
|
||||
# regression can never reach main or a release tag unchecked.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel superseded runs on the same ref to save CI minutes. Unlike the release
|
||||
# workflow, an interrupted CI run has no side effects.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
server/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
# The server test suite (test-server-ws/routes/ops) imports express,
|
||||
# socket.io, and dotenv from server/node_modules, so install them too.
|
||||
- name: Install server dependencies
|
||||
run: npm ci
|
||||
working-directory: server
|
||||
|
||||
- name: Run verification suite
|
||||
run: npm run verify
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Build Extensions
|
||||
run: |
|
||||
npm install
|
||||
npm ci
|
||||
npm run build:extension
|
||||
|
||||
- name: Generate artifact attestation for extensions
|
||||
|
||||
@@ -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-v2.4.2-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.4.6-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>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.4.2 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.4.6 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
@@ -59,7 +59,7 @@ The easiest and safest way to install KoalaSync is directly through the official
|
||||
### 🌐 Localization & Translations
|
||||
|
||||
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
|
||||
- **Available Languages**: Support is included for 13 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
|
||||
- **Available Languages**: Support is included for 15 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), Chinese (Simplified) (`zh`), Ukrainian (`uk`), and European Portuguese (`pt`).
|
||||
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
|
||||
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](docs/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
|
||||
|
||||
|
||||
@@ -4,6 +4,24 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.4.6] — 2026-06-23
|
||||
|
||||
### Fixed
|
||||
- **Room and settings are no longer stored in `chrome.storage.sync`** — Room ID, password, and username were being resurrected from synced storage on a fresh install (sync survives an uninstall in the user's Google account), which made the extension silently auto-connect to a dead room and appear permanently connected. `getSettings()` and all settings reads are now local-only, and legacy keys are actively purged from sync on install/update/startup. Only `onboardingComplete` and `dismissedHints` remain in sync.
|
||||
- **No server traffic while alone in a room** — When you are the only peer, heartbeats, force-sync, and episode auto-sync are now fully suppressed (previously the keepAlive heartbeat, force-sync, and episode lobby were still broadcast to an empty room). The solo state is re-evaluated live on every event — never cached — so the instant another peer joins, syncing resumes immediately, including an instant state push so the newcomer sees your current position without waiting for the next heartbeat.
|
||||
|
||||
## [v2.4.4] — 2026-06-23
|
||||
|
||||
### Changed
|
||||
- **Server: Event rate limit raised 30 → 50 per 10s**, and all connection/event/health rate-limit thresholds and windows extracted into named constants.
|
||||
- **Extension: Reconnect backoff tuned and jittered** — capped at ~8 attempts/60s (under the per-IP connection limit) with ±20% jitter to de-synchronize reconnect herds after a server blip.
|
||||
- **CI: Added a verification workflow** running lint, tests, audits, and builds on every push/PR; the release build now uses `npm ci`.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Offline event-queue flush is now paced** (small batches instead of one synchronous burst) so a reconnect after a long outage no longer trips the server event limit and gets disconnected on rejoin.
|
||||
- **Extension: Ping liveness tolerates one missed PONG** — a reconnect is forced only after 2 consecutive misses (~20s) instead of a single 5s timeout, avoiding spurious drops under transient load.
|
||||
- **Extension: `socket.send()` failures are caught and re-queued** instead of losing the event on a disconnect race.
|
||||
|
||||
## [v2.4.3] — 2026-06-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -126,17 +126,13 @@ function setCustomParam(param, value) {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
let audioData = (await chrome.storage.local.get(['audioSettings'])).audioSettings;
|
||||
const syncData = await chrome.storage.sync.get(['audioSettings', 'locale']);
|
||||
if (!audioData && syncData.audioSettings) {
|
||||
audioData = syncData.audioSettings;
|
||||
await chrome.storage.local.set({ audioSettings: audioData });
|
||||
}
|
||||
const lang = syncData.locale || getSystemLanguage();
|
||||
// Local-only: audioSettings/locale are never read from storage.sync.
|
||||
const { audioSettings, locale } = await chrome.storage.local.get(['audioSettings', 'locale']);
|
||||
const lang = locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
translateDOM();
|
||||
|
||||
currentSettings = mergeAudioSettings(audioData);
|
||||
currentSettings = mergeAudioSettings(audioSettings);
|
||||
render();
|
||||
}
|
||||
|
||||
|
||||
+193
-133
@@ -2,6 +2,7 @@ import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, E
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
let uninstallURLInitPromise = null;
|
||||
@@ -42,11 +43,13 @@ async function initUninstallURL() {
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install' || details.reason === 'update') {
|
||||
initUninstallURL();
|
||||
purgeLegacySyncKeys();
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
initUninstallURL();
|
||||
purgeLegacySyncKeys();
|
||||
});
|
||||
|
||||
// --- State Management ---
|
||||
@@ -62,6 +65,7 @@ let storageInitialized = false;
|
||||
let pendingLogs = [];
|
||||
let pendingHistory = [];
|
||||
let eventQueue = [];
|
||||
let flushTimer = null; // paces draining of eventQueue after (re)connect
|
||||
let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let localSeq = 0; // Monotonically increasing command sequence for this peer
|
||||
@@ -74,6 +78,7 @@ let pingInterval = null;
|
||||
let pingTimeout = null;
|
||||
let pendingPingT = null;
|
||||
let currentPingMs = null;
|
||||
let missedPongs = 0;
|
||||
|
||||
// --- Keep-Alive Port Listener ---
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
@@ -197,8 +202,28 @@ let roomIdleSince = null;
|
||||
let lastContentHeartbeatAt = null;
|
||||
let connectIntent = false;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
|
||||
// keeping a single client comfortably under the server's per-IP connection
|
||||
// budget (10/min) even before jitter. Cumulative (no jitter): 1, 2.8, 6, 11.9,
|
||||
// 22.4, 34.4, 46.4, 58.4s → 8th attempt at ~58s.
|
||||
const _RECONNECT_BASE_DELAY = 1000;
|
||||
const _RECONNECT_MAX_DELAY = 12000;
|
||||
const _RECONNECT_FACTOR = 1.8;
|
||||
const _RECONNECT_GIVEUP_MS = 300000; // switch to slow mode after 5 min of fast retries
|
||||
const _RECONNECT_SLOW_DELAY = 300000; // slow-mode interval: every 5 min
|
||||
const _RECONNECT_JITTER = 0.2; // ±20% randomization to de-synchronize reconnect herds
|
||||
// Paced queue flush: after a (re)connect we drain the offline event backlog in
|
||||
// small batches instead of one synchronous burst, so we stay well under the
|
||||
// server's per-socket event budget (50 / 10s) and leave headroom for the
|
||||
// heartbeats/pings/commands that also count toward it. 10 per 3s ≈ 33/10s.
|
||||
const FLUSH_BATCH_SIZE = 10;
|
||||
const FLUSH_BATCH_INTERVAL_MS = 3000;
|
||||
// Ping liveness: a single unanswered ping is tolerated (transient network
|
||||
// blip); only MAX_MISSED_PONGS consecutive misses force a reconnect. With a
|
||||
// 15s interval and 5s timeout that means ~20s to detect a genuinely dead link.
|
||||
const PING_INTERVAL_MS = 15000;
|
||||
const PING_TIMEOUT_MS = 5000;
|
||||
const MAX_MISSED_PONGS = 2;
|
||||
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Force Sync Coordination
|
||||
@@ -263,29 +288,14 @@ async function getPeerId() {
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
// Try local (per-device) first, fall back to sync for migration
|
||||
let data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
|
||||
let migrated = false;
|
||||
if (!data.username) {
|
||||
const syncData = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
|
||||
if (syncData.username || syncData.roomId) {
|
||||
data = syncData;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
// Local-only by design. Room credentials (roomId/password) and identity
|
||||
// (username) must NEVER come from storage.sync — syncing them across devices
|
||||
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
||||
// has empty local storage but sync survives in the user's Google account).
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
}
|
||||
if (migrated) {
|
||||
await chrome.storage.local.set({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username
|
||||
});
|
||||
} else if (!data.username) {
|
||||
await chrome.storage.local.set({ username });
|
||||
}
|
||||
return {
|
||||
@@ -297,6 +307,19 @@ async function getSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
// Privacy + correctness: only onboardingComplete and dismissedHints belong in
|
||||
// storage.sync. Everything else is per-device local storage. This actively
|
||||
// removes legacy keys that older versions wrote to sync (and that would
|
||||
// otherwise be redistributed across devices and resurrected on reinstall).
|
||||
const LEGACY_SYNC_KEYS = [
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'username',
|
||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings'
|
||||
];
|
||||
function purgeLegacySyncKeys() {
|
||||
chrome.storage.sync.remove(LEGACY_SYNC_KEYS).catch(() => {});
|
||||
}
|
||||
|
||||
function addLog(message, type = 'info') {
|
||||
const log = {
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -349,6 +372,7 @@ function forceDisconnect() {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
forceSyncAcks.clear();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
@@ -546,12 +570,7 @@ async function connect() {
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
while (eventQueue.length > 0) {
|
||||
const queuedMsg = eventQueue.shift();
|
||||
emit(queuedMsg.event, queuedMsg.data);
|
||||
}
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({ eventQueue: [] });
|
||||
flushEventQueue();
|
||||
} else if (msg.startsWith('42')) {
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
@@ -570,6 +589,7 @@ async function connect() {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
stopPing();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
|
||||
if (!connectIntent && !currentRoom) {
|
||||
isForceSyncInitiator = false;
|
||||
@@ -619,6 +639,12 @@ async function connect() {
|
||||
|
||||
|
||||
function broadcastConnectionStatus(status) {
|
||||
// No room and no intent to connect → this isn't a failure, it's the normal
|
||||
// resting state. Surface a distinct 'idle' status so the UI can say
|
||||
// "ready to connect" instead of a misleading red "Disconnected".
|
||||
if (status === 'disconnected' && !currentRoom && !connectIntent) {
|
||||
status = 'idle';
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
@@ -688,17 +714,22 @@ function scheduleReconnect() {
|
||||
const elapsed = Date.now() - reconnectStartTime;
|
||||
reconnectAttempts++;
|
||||
|
||||
if (!reconnectFailed && (elapsed > 300000 || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
|
||||
if (!reconnectFailed && (elapsed > _RECONNECT_GIVEUP_MS || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
|
||||
reconnectFailed = true;
|
||||
addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn');
|
||||
}
|
||||
|
||||
const delay = reconnectFailed
|
||||
? 300000
|
||||
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
const baseDelay = reconnectFailed
|
||||
? _RECONNECT_SLOW_DELAY
|
||||
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(_RECONNECT_FACTOR, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
// Jitter de-synchronizes herds: many clients dropped by the same server
|
||||
// blip won't all reconnect on the same tick and exhaust the connection
|
||||
// budget in lockstep. Applied in both fast and slow mode.
|
||||
const jitterFactor = 1 - _RECONNECT_JITTER + Math.random() * 2 * _RECONNECT_JITTER;
|
||||
const delay = Math.round(baseDelay * jitterFactor);
|
||||
|
||||
if (reconnectFailed) {
|
||||
addLog(`Slow reconnect in 5min (attempt ${reconnectAttempts})`, 'info');
|
||||
addLog(`Slow reconnect in ~5min (attempt ${reconnectAttempts})`, 'info');
|
||||
} else {
|
||||
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
|
||||
}
|
||||
@@ -716,17 +747,58 @@ function scheduleReconnect() {
|
||||
function emit(event, data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
const msg = `42${JSON.stringify([event, data])}`;
|
||||
socket.send(msg);
|
||||
} else {
|
||||
eventQueue.push({ event, data });
|
||||
if (eventQueue.length > 50) {
|
||||
eventQueue.shift();
|
||||
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
||||
try {
|
||||
socket.send(msg);
|
||||
} catch (e) {
|
||||
// The socket can close between the readyState check and send()
|
||||
// (race with a server-side disconnect). Re-queue so the event is
|
||||
// retried on the next successful (re)connect instead of being lost.
|
||||
addLog(`Send failed, re-queueing ${event}: ${e.message}`, 'warn');
|
||||
queueEvent(event, data);
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue });
|
||||
} else {
|
||||
queueEvent(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
function queueEvent(event, data) {
|
||||
eventQueue.push({ event, data });
|
||||
if (eventQueue.length > 50) {
|
||||
eventQueue.shift();
|
||||
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the offline event queue in paced batches. A reconnect after a long
|
||||
* outage can leave up to 50 queued events; dumping them in one tick would
|
||||
* exceed the server's per-socket event budget and get us disconnected right
|
||||
* after rejoining. We send FLUSH_BATCH_SIZE events, then wait
|
||||
* FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across
|
||||
* subsequent batches; if the connection drops mid-drain, the rest stay queued.
|
||||
*/
|
||||
function flushEventQueue() {
|
||||
if (flushTimer) return; // a drain is already in progress
|
||||
const drainBatch = () => {
|
||||
flushTimer = null;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
return; // lost the connection — leave the rest queued for next connect
|
||||
}
|
||||
let sent = 0;
|
||||
while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) {
|
||||
const queuedMsg = eventQueue.shift();
|
||||
emit(queuedMsg.event, queuedMsg.data);
|
||||
sent++;
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue }).catch(() => {});
|
||||
if (eventQueue.length > 0) {
|
||||
flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
drainBatch();
|
||||
}
|
||||
|
||||
function addToHistory(action, senderId) {
|
||||
const historyEntry = {
|
||||
action,
|
||||
@@ -750,16 +822,23 @@ function sendPing() {
|
||||
emit(EVENTS.PING, { t });
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
pingTimeout = setTimeout(() => {
|
||||
if (pendingPingT === t) {
|
||||
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
|
||||
pendingPingT = null;
|
||||
pingTimeout = null;
|
||||
if (pendingPingT !== t) return; // a PONG arrived in time
|
||||
// This ping went unanswered. Tolerate transient blips: only force a
|
||||
// reconnect after MAX_MISSED_PONGS consecutive misses, not the first.
|
||||
pendingPingT = null;
|
||||
missedPongs++;
|
||||
if (missedPongs >= MAX_MISSED_PONGS) {
|
||||
addLog(`${missedPongs} consecutive pings unanswered — force disconnecting to trigger reconnect`, 'warn');
|
||||
missedPongs = 0;
|
||||
forceDisconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
} else {
|
||||
addLog(`Ping unanswered (${missedPongs}/${MAX_MISSED_PONGS}) — retrying next interval`, 'warn');
|
||||
}
|
||||
pingTimeout = null;
|
||||
}, 5000);
|
||||
}, PING_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function startPing() {
|
||||
@@ -767,7 +846,8 @@ function startPing() {
|
||||
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
|
||||
currentPingMs = null;
|
||||
pendingPingT = null;
|
||||
pingInterval = setInterval(sendPing, 15000);
|
||||
missedPongs = 0;
|
||||
pingInterval = setInterval(sendPing, PING_INTERVAL_MS);
|
||||
sendPing();
|
||||
}
|
||||
|
||||
@@ -782,6 +862,7 @@ function stopPing() {
|
||||
}
|
||||
currentPingMs = null;
|
||||
pendingPingT = null;
|
||||
missedPongs = 0;
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
@@ -1006,13 +1087,21 @@ function handleServerEvent(event, data) {
|
||||
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
|
||||
if (data.status === 'joined') {
|
||||
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
|
||||
const wasSolo = currentRoom.peers.filter(p => (p.peerId || p) !== peerId).length === 0;
|
||||
delete lastSeqBySender[data.peerId];
|
||||
_persistLastSeq();
|
||||
|
||||
|
||||
currentRoom.peers.push(createPeerData(data));
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
|
||||
// We were alone and now we're not — proactively push our
|
||||
// current playback state so the newcomer syncs immediately
|
||||
// instead of waiting up to a full heartbeat interval.
|
||||
if (wasSolo && currentTabId) {
|
||||
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
||||
}
|
||||
|
||||
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: episodeLobby.expectedTitle });
|
||||
}
|
||||
@@ -1122,6 +1211,7 @@ function handleServerEvent(event, data) {
|
||||
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
|
||||
if (pendingPingT === data.t) {
|
||||
pendingPingT = null;
|
||||
missedPongs = 0;
|
||||
if (pingTimeout) {
|
||||
clearTimeout(pingTimeout);
|
||||
pingTimeout = null;
|
||||
@@ -1374,14 +1464,18 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
await leaveRoomAfterIdleGrace('Left room after 2 hours without a selected video heartbeat.');
|
||||
return;
|
||||
}
|
||||
// Heartbeat Logic: Always include identity metadata
|
||||
const settings = await getSettings();
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
status: 'heartbeat',
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle
|
||||
});
|
||||
// Heartbeat — only broadcast when someone else is in the room.
|
||||
// Recomputed live so a freshly joined peer is picked up immediately.
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount > 0) {
|
||||
const settings = await getSettings();
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
status: 'heartbeat',
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1418,14 +1512,8 @@ function resetAudioProcessingInTab(tabId) {
|
||||
|
||||
async function applyAudioSettingsToTab(tabId) {
|
||||
if (!tabId) return;
|
||||
let data = (await chrome.storage.local.get(['audioSettings']));
|
||||
if (!data.audioSettings) {
|
||||
const syncData = await chrome.storage.sync.get(['audioSettings']);
|
||||
if (syncData.audioSettings) {
|
||||
data = syncData;
|
||||
await chrome.storage.local.set({ audioSettings: syncData.audioSettings });
|
||||
}
|
||||
}
|
||||
// Local-only: audioSettings are never read from storage.sync.
|
||||
const data = await chrome.storage.local.get(['audioSettings']);
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: data.audioSettings
|
||||
@@ -1492,6 +1580,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const isReconnecting = !isConnected && reconnectAttempts > 0;
|
||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||
// Distinguish the normal "not in a room" resting state from a real drop.
|
||||
if (status === 'disconnected' && !currentRoom && !connectIntent) status = 'idle';
|
||||
sendResponse({
|
||||
status,
|
||||
peerId,
|
||||
@@ -1636,6 +1726,19 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
});
|
||||
} else if (message.type === 'CONTENT_EVENT') {
|
||||
const processEvent = () => {
|
||||
// Live solo check — recomputed from the current peer list on every
|
||||
// event (the list is updated synchronously on PEER_STATUS join/leave),
|
||||
// never cached, so the instant a peer joins we resume sending.
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
const hasOtherPeers = otherCount > 0;
|
||||
|
||||
// Force Sync only makes sense with other peers. Solo it is a no-op:
|
||||
// skip the pause/seek + ACK-wait entirely (no freeze, no server traffic).
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE && !hasOtherPeers) {
|
||||
sendResponse({ status: 'ok_solo' });
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
@@ -1679,11 +1782,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}, FORCE_SYNC_TIMEOUT);
|
||||
}
|
||||
addToHistory(message.action, 'You');
|
||||
|
||||
|
||||
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
const hasOtherPeers = otherCount > 0;
|
||||
|
||||
if (isNonEssentialEvent && !hasOtherPeers) {
|
||||
sendResponse({ status: 'ok_solo' });
|
||||
return;
|
||||
@@ -1843,6 +1943,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Variant A: alone in the room → no one to wait for. Skip the lobby
|
||||
// entirely so the next episode just plays through (no pause, no traffic).
|
||||
// Live peer check, so the moment someone joins the next transition syncs.
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount === 0) {
|
||||
addLog(`Episode change ("${newTitle}") — alone in room, playing through without a lobby.`, 'info');
|
||||
sendResponse({ status: 'solo_no_lobby' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If lobby already exists for this title, just mark self ready
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, newTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
@@ -1922,72 +2032,22 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
await ensureState();
|
||||
if (!currentTabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(currentTabId, {
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Tab removal listener
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
if (tabId === currentTabId) {
|
||||
const wasInRoom = !!currentRoom;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
roomIdleSince = Date.now();
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, roomIdleSince, lastContentHeartbeatAt });
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
if (wasInRoom) {
|
||||
const roomAtClose = currentRoom;
|
||||
getSettings().then(settings => {
|
||||
if (currentRoom !== roomAtClose) return;
|
||||
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
playbackState: 'paused',
|
||||
currentTime: null,
|
||||
mediaTitle: null,
|
||||
username: settings.username,
|
||||
tabTitle: null
|
||||
});
|
||||
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
||||
if (me && typeof me === 'object') {
|
||||
me.playbackState = 'paused';
|
||||
me.currentTime = null;
|
||||
me.mediaTitle = null;
|
||||
me.tabTitle = null;
|
||||
me.lastHeartbeat = Date.now();
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-inject on full page refresh
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
await ensureState();
|
||||
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
})
|
||||
.then(() => applyAudioSettingsToTab(tabId))
|
||||
.catch(() => {});
|
||||
}
|
||||
initTabManager({
|
||||
getCurrentTabId: () => currentTabId,
|
||||
setCurrentTabId: (val) => { currentTabId = val; },
|
||||
setCurrentTabTitle: (val) => { currentTabTitle = val; },
|
||||
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
|
||||
setRoomIdleSince: (val) => { roomIdleSince = val; },
|
||||
getCurrentRoom: () => currentRoom,
|
||||
getPeerId: () => peerId,
|
||||
getStorageInitialized: () => storageInitialized,
|
||||
updateBadgeStatus,
|
||||
addLog,
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
ensureState,
|
||||
EVENTS
|
||||
});
|
||||
|
||||
// Initial Connect — only if user has an active room configuration
|
||||
|
||||
+9
-18
@@ -89,25 +89,8 @@
|
||||
let _audioSettings = null;
|
||||
let _audioProcessingAllowed = true;
|
||||
|
||||
// Cache the autoSyncNextEpisode setting
|
||||
// Cache the autoSyncNextEpisode setting (local-only; never read from sync)
|
||||
chrome.storage.local.get(['autoSyncNextEpisode', 'audioSettings'], (data) => {
|
||||
if (data.autoSyncNextEpisode === undefined || data.audioSettings === undefined) {
|
||||
chrome.storage.sync.get(['autoSyncNextEpisode', 'audioSettings'], (syncData) => {
|
||||
const migrate = {};
|
||||
if (data.autoSyncNextEpisode === undefined && syncData.autoSyncNextEpisode !== undefined) {
|
||||
migrate.autoSyncNextEpisode = syncData.autoSyncNextEpisode;
|
||||
}
|
||||
if (data.audioSettings === undefined && syncData.audioSettings !== undefined) {
|
||||
migrate.audioSettings = syncData.audioSettings;
|
||||
}
|
||||
if (Object.keys(migrate).length) chrome.storage.local.set(migrate);
|
||||
_autoSyncEnabled = syncData.autoSyncNextEpisode !== false;
|
||||
_audioSettings = mergeAudioSettings(syncData.audioSettings);
|
||||
const v = findVideo();
|
||||
if (v && _audioProcessingAllowed) applyAudioSettings(v, _audioSettings);
|
||||
});
|
||||
return;
|
||||
}
|
||||
_autoSyncEnabled = data.autoSyncNextEpisode !== false;
|
||||
_audioSettings = mergeAudioSettings(data.audioSettings);
|
||||
const video = findVideo();
|
||||
@@ -555,6 +538,14 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// Background asks for an immediate state push (e.g. the first peer just
|
||||
// joined while we were solo) so the newcomer syncs without waiting.
|
||||
if (message.type === 'REQUEST_HEARTBEAT') {
|
||||
sendHeartbeat();
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Verbinde...",
|
||||
"STATUS_FAILED": "Fehlgeschlagen",
|
||||
"STATUS_DISCONNECTED": "Getrennt",
|
||||
"STATUS_IDLE": "Bereit zum Verbinden",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync ist bereit. Tritt einem Raum bei oder erstelle einen, um die Verbindung herzustellen und die Synchronisierung zu starten.",
|
||||
"BTN_STATE_JOINING": "🚀 Trete bei...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Verbinde erneut...",
|
||||
"BTN_STATE_PLAYING": "▶ Spiele ab...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Connecting...",
|
||||
"STATUS_FAILED": "Failed",
|
||||
"STATUS_DISCONNECTED": "Disconnected",
|
||||
"STATUS_IDLE": "Ready to connect",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync is ready. Join or create a room to connect and start syncing.",
|
||||
"BTN_STATE_JOINING": "🚀 Joining...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconnecting...",
|
||||
"BTN_STATE_PLAYING": "▶ Playing...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Conectando...",
|
||||
"STATUS_FAILED": "Error",
|
||||
"STATUS_DISCONNECTED": "Desconectado",
|
||||
"STATUS_IDLE": "Listo para conectar",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync está listo. Únete o crea una sala para conectarte y empezar a sincronizar.",
|
||||
"BTN_STATE_JOINING": "🚀 Uniéndose...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
|
||||
"BTN_STATE_PLAYING": "▶ Reproduciendo...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Connexion...",
|
||||
"STATUS_FAILED": "Échec",
|
||||
"STATUS_DISCONNECTED": "Déconnecté",
|
||||
"STATUS_IDLE": "Prêt à se connecter",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync est prêt. Rejoignez ou créez un salon pour vous connecter et commencer la synchronisation.",
|
||||
"BTN_STATE_JOINING": "🚀 Connexion...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconnexion...",
|
||||
"BTN_STATE_PLAYING": "▶ Lecture...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Connessione in corso...",
|
||||
"STATUS_FAILED": "Errore",
|
||||
"STATUS_DISCONNECTED": "Disconnesso",
|
||||
"STATUS_IDLE": "Pronto a connettersi",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync è pronto. Entra o crea una stanza per connetterti e avviare la sincronizzazione.",
|
||||
"BTN_STATE_JOINING": "🚀 Entrando...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Riconnessione...",
|
||||
"BTN_STATE_PLAYING": "▶ In riproduzione...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "接続中...",
|
||||
"STATUS_FAILED": "失敗",
|
||||
"STATUS_DISCONNECTED": "切断されました",
|
||||
"STATUS_IDLE": "接続準備完了",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSyncの準備ができました。ルームに参加するか作成して接続し、同期を開始してください。",
|
||||
"BTN_STATE_JOINING": "🚀 参加中...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 再接続中...",
|
||||
"BTN_STATE_PLAYING": "▶ 再生中...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "연결 중...",
|
||||
"STATUS_FAILED": "실패",
|
||||
"STATUS_DISCONNECTED": "연결 끊김",
|
||||
"STATUS_IDLE": "연결 준비 완료",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync가 준비되었습니다. 방에 참여하거나 생성하여 연결하고 동기화를 시작하세요.",
|
||||
"BTN_STATE_JOINING": "🚀 참여 중...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 재연결 중...",
|
||||
"BTN_STATE_PLAYING": "▶ 재생 중...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Verbinden...",
|
||||
"STATUS_FAILED": "Mislukt",
|
||||
"STATUS_DISCONNECTED": "Verbinding verbroken",
|
||||
"STATUS_IDLE": "Gereed om te verbinden",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync is gereed. Neem deel aan een kamer of maak er een aan om te verbinden en te synchroniseren.",
|
||||
"BTN_STATE_JOINING": "🚀 Deelnemen...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Opnieuw verbinden...",
|
||||
"BTN_STATE_PLAYING": "▶ Afspelen...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Łączenie...",
|
||||
"STATUS_FAILED": "Nieudane",
|
||||
"STATUS_DISCONNECTED": "Rozłączono",
|
||||
"STATUS_IDLE": "Gotowy do połączenia",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync jest gotowy. Dołącz do pokoju lub utwórz go, aby się połączyć i rozpocząć synchronizację.",
|
||||
"BTN_STATE_JOINING": "🚀 Dołączanie...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Ponowne łączenie...",
|
||||
"BTN_STATE_PLAYING": "▶ Odtwarzanie...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Conectando...",
|
||||
"STATUS_FAILED": "Falhou",
|
||||
"STATUS_DISCONNECTED": "Desconectado",
|
||||
"STATUS_IDLE": "Pronto para conectar",
|
||||
"STATUS_IDLE_TOOLTIP": "O KoalaSync está pronto. Entre ou crie uma sala para conectar e começar a sincronizar.",
|
||||
"BTN_STATE_JOINING": "🚀 Entrando...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
|
||||
"BTN_STATE_PLAYING": "▶ Reproduzindo...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "A ligar...",
|
||||
"STATUS_FAILED": "Falhou",
|
||||
"STATUS_DISCONNECTED": "Desligado",
|
||||
"STATUS_IDLE": "Pronto para ligar",
|
||||
"STATUS_IDLE_TOOLTIP": "O KoalaSync está pronto. Entre ou crie uma sala para ligar e iniciar a sincronização.",
|
||||
"BTN_STATE_JOINING": "🚀 A entrar...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 A voltar a ligar...",
|
||||
"BTN_STATE_PLAYING": "▶ A reproduzir...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Подключение...",
|
||||
"STATUS_FAILED": "Ошибка",
|
||||
"STATUS_DISCONNECTED": "Отключено",
|
||||
"STATUS_IDLE": "Готов к подключению",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync готов. Присоединитесь к комнате или создайте её, чтобы подключиться и начать синхронизацию.",
|
||||
"BTN_STATE_JOINING": "🚀 Вход...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Переподключение...",
|
||||
"BTN_STATE_PLAYING": "▶ Запуск...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Bağlanılıyor...",
|
||||
"STATUS_FAILED": "Başarısız",
|
||||
"STATUS_DISCONNECTED": "Bağlantı Kesildi",
|
||||
"STATUS_IDLE": "Bağlanmaya hazır",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync hazır. Bağlanmak ve senkronizasyonu başlatmak için bir odaya katılın veya oluşturun.",
|
||||
"BTN_STATE_JOINING": "🚀 Katılınıyor...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Yeniden bağlanılıyor...",
|
||||
"BTN_STATE_PLAYING": "▶ Oynatılıyor...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "Підключення...",
|
||||
"STATUS_FAILED": "Не вдалося",
|
||||
"STATUS_DISCONNECTED": "Відключено",
|
||||
"STATUS_IDLE": "Готовий до підключення",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync готовий. Приєднайтеся до кімнати або створіть її, щоб підключитися та почати синхронізацію.",
|
||||
"BTN_STATE_JOINING": "🚀 Приєднуюсь...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Повторне підключення...",
|
||||
"BTN_STATE_PLAYING": "▶ Грає...",
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
"STATUS_CONNECTING": "正在连接...",
|
||||
"STATUS_FAILED": "失败的",
|
||||
"STATUS_DISCONNECTED": "已断开连接",
|
||||
"STATUS_IDLE": "已准备好连接",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync 已准备就绪。加入或创建房间以连接并开始同步。",
|
||||
"BTN_STATE_JOINING": "🚀 正在加入...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 正在重新连接...",
|
||||
"BTN_STATE_PLAYING": "▶ 播放中...",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.6",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
setCurrentTabId,
|
||||
setCurrentTabTitle,
|
||||
setLastContentHeartbeatAt,
|
||||
setRoomIdleSince,
|
||||
getCurrentRoom,
|
||||
getPeerId,
|
||||
getStorageInitialized,
|
||||
updateBadgeStatus,
|
||||
addLog,
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
ensureState,
|
||||
EVENTS
|
||||
}) {
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
await ensureState();
|
||||
const tabId = getCurrentTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
if (tabId === getCurrentTabId()) {
|
||||
const wasInRoom = !!getCurrentRoom();
|
||||
setCurrentTabId(null);
|
||||
setCurrentTabTitle(null);
|
||||
setLastContentHeartbeatAt(null);
|
||||
const now = Date.now();
|
||||
setRoomIdleSince(now);
|
||||
chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: now,
|
||||
lastContentHeartbeatAt: null
|
||||
});
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
if (wasInRoom) {
|
||||
const roomAtClose = getCurrentRoom();
|
||||
getSettings().then(settings => {
|
||||
if (getCurrentRoom() !== roomAtClose) return;
|
||||
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId: getPeerId(),
|
||||
playbackState: 'paused',
|
||||
currentTime: null,
|
||||
mediaTitle: null,
|
||||
username: settings.username,
|
||||
tabTitle: null
|
||||
});
|
||||
|
||||
const room = getCurrentRoom();
|
||||
if (room && Array.isArray(room.peers)) {
|
||||
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
|
||||
if (me && typeof me === 'object') {
|
||||
me.playbackState = 'paused';
|
||||
me.currentTime = null;
|
||||
me.mediaTitle = null;
|
||||
me.tabTitle = null;
|
||||
me.lastHeartbeat = Date.now();
|
||||
if (getStorageInitialized()) {
|
||||
chrome.storage.session.set({ currentRoom: room });
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
await ensureState();
|
||||
const curTabId = getCurrentTabId();
|
||||
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
})
|
||||
.then(() => applyAudioSettingsToTab(tabId))
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -613,7 +613,10 @@
|
||||
<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>
|
||||
<span id="connText" style="flex:1;">Disconnected</span>
|
||||
<span style="flex:1; display:flex; align-items:center; gap:6px; min-width:0;">
|
||||
<span id="connText">Disconnected</span>
|
||||
<span id="connInfo" data-i18n-title="STATUS_IDLE_TOOLTIP" title="KoalaSync is ready. Join or create a room to connect and start syncing." style="display:none; cursor:help; flex:none; width:15px; height:15px; line-height:14px; text-align:center; border-radius:50%; border:1px solid var(--text-muted); color:var(--text-muted); font-size:10px; font-weight:700; font-style:normal;">i</span>
|
||||
</span>
|
||||
<span id="connPing" style="font-size:11px; font-family:monospace; font-weight:600; opacity:0.8;"></span>
|
||||
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px; margin: 0;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
|
||||
|
||||
+26
-16
@@ -16,6 +16,7 @@ const elements = {
|
||||
clearLogs: document.getElementById('clearLogs'),
|
||||
connDot: document.getElementById('connDot'),
|
||||
connText: document.getElementById('connText'),
|
||||
connInfo: document.getElementById('connInfo'),
|
||||
connPing: document.getElementById('connPing'),
|
||||
serverUrl: document.getElementById('serverUrl'),
|
||||
serverOfficial: document.getElementById('serverOfficial'),
|
||||
@@ -175,19 +176,9 @@ function setRoomRefreshCooldown() {
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Local-only by design — settings and room credentials never come from
|
||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
|
||||
// Migrate preferences from sync → local for existing users
|
||||
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
|
||||
const toMigrate = {};
|
||||
for (const key of ['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']) {
|
||||
if (localData[key] === undefined && oldSync[key] !== undefined) {
|
||||
toMigrate[key] = oldSync[key];
|
||||
localData[key] = oldSync[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(toMigrate).length) {
|
||||
await chrome.storage.local.set(toMigrate);
|
||||
}
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -255,9 +246,9 @@ async function init() {
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
// If user has a room configured but background is not connected,
|
||||
// If user has a room configured but background is not connected (disconnected or idle),
|
||||
// trigger connection now — the popup opening is explicit user intent.
|
||||
if (res.status === 'disconnected' && localData.roomId) {
|
||||
if ((res.status === 'disconnected' || res.status === 'idle') && localData.roomId) {
|
||||
chrome.runtime.sendMessage({ type: 'CONNECT' }).catch(() => {});
|
||||
applyConnectionStatus('connecting');
|
||||
}
|
||||
@@ -828,19 +819,33 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
}
|
||||
|
||||
function applyConnectionStatus(status) {
|
||||
// Coerce 'idle' to 'disconnected' if a room is actively configured.
|
||||
// An 'idle' status with a configured room indicates the background
|
||||
// worker dropped connection intent due to a server error.
|
||||
if (status === 'idle' && elements.sectionActive && elements.sectionActive.style.display !== 'none') {
|
||||
status = 'disconnected';
|
||||
}
|
||||
|
||||
const connected = status === 'connected';
|
||||
const connecting = status === 'connecting';
|
||||
const reconnecting = status === 'reconnecting';
|
||||
// 'idle' = not in a room and not trying to connect. This is the normal
|
||||
// resting state (lazy connect), NOT an error — surface it neutrally.
|
||||
const idle = status === 'idle';
|
||||
|
||||
if (elements.connDot) {
|
||||
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : ((connecting || reconnecting) ? 'status-online' : 'status-offline'));
|
||||
|
||||
|
||||
if (reconnecting) {
|
||||
elements.connDot.style.background = '#f59e0b';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #f59e0b';
|
||||
} else if (connecting) {
|
||||
elements.connDot.style.background = '#fbbf24';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
|
||||
} else if (idle) {
|
||||
// Neutral grey — ready, not failed.
|
||||
elements.connDot.style.background = '#9ca3af';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
} else if (!connected) {
|
||||
elements.connDot.style.background = '#ef4444';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
@@ -851,7 +856,12 @@ function applyConnectionStatus(status) {
|
||||
}
|
||||
|
||||
if (elements.connText) {
|
||||
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : getMessage('STATUS_DISCONNECTED')));
|
||||
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : (idle ? getMessage('STATUS_IDLE') : getMessage('STATUS_DISCONNECTED'))));
|
||||
}
|
||||
|
||||
// Show the info "i" + tooltip only in the idle state.
|
||||
if (elements.connInfo) {
|
||||
elements.connInfo.style.display = idle ? '' : 'none';
|
||||
}
|
||||
if (!connected) {
|
||||
updatePingDisplay(null);
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.6",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -8,6 +8,7 @@
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node scripts/verify-release.mjs",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
roomListCooldowns,
|
||||
rateLimitDenied,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup
|
||||
stopRateLimitCleanup,
|
||||
CONNECTION_RATE_LIMIT,
|
||||
EVENT_RATE_LIMIT
|
||||
} from '../server/rate-limiter.js';
|
||||
|
||||
// Helper: mock io for cleanup
|
||||
@@ -31,8 +33,9 @@ function reset() {
|
||||
// --- checkConnectionRate ---
|
||||
reset();
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
|
||||
for (let i = 0; i < 9; i++) checkConnectionRate('1.1.1.1');
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), false, '11th connection blocked');
|
||||
// Exhaust the rest of the budget (first call above counted as 1).
|
||||
for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1');
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window blocked`);
|
||||
assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented');
|
||||
|
||||
reset();
|
||||
@@ -41,8 +44,9 @@ assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent');
|
||||
// --- checkEventRate ---
|
||||
reset();
|
||||
assert.equal(checkEventRate('sock1'), true, 'first event allowed');
|
||||
for (let i = 0; i < 29; i++) checkEventRate('sock1');
|
||||
assert.equal(checkEventRate('sock1'), false, '31st event blocked');
|
||||
// Exhaust the rest of the budget (first call above counted as 1).
|
||||
for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1');
|
||||
assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`);
|
||||
assert.equal(rateLimitDenied.events, 1);
|
||||
|
||||
reset();
|
||||
|
||||
+18
-10
@@ -7,6 +7,14 @@ export const ROOM_LIST_COOLDOWN_MS = 10000;
|
||||
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
||||
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
||||
|
||||
// --- Connection & event budgets (formerly inline magic numbers) ---
|
||||
export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP per window
|
||||
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
|
||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
|
||||
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
@@ -72,30 +80,30 @@ export function recordAuthFailure(ip, roomId) {
|
||||
|
||||
export function checkConnectionRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + CONNECTION_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CONNECTION_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
connectionCounts.set(ip, entry);
|
||||
if (entry.count <= 10) return true;
|
||||
if (entry.count <= CONNECTION_RATE_LIMIT) return true;
|
||||
rateLimitDenied.connections++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkEventRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
|
||||
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + EVENT_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + EVENT_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
eventCounts.set(socketId, entry);
|
||||
if (entry.count <= 30) return true;
|
||||
if (entry.count <= EVENT_RATE_LIMIT) return true;
|
||||
rateLimitDenied.events++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + HEALTH_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
@@ -105,8 +113,8 @@ export function checkHealthRate(ip) {
|
||||
|
||||
export function checkAdminMetricsAuthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + ADMIN_METRICS_AUTH_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + ADMIN_METRICS_AUTH_WINDOW_MS; }
|
||||
entry.count++;
|
||||
adminMetricsAuthCounts.set(ip, entry);
|
||||
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "2.4.2";
|
||||
export const APP_VERSION = "2.4.6";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
|
||||
+17
-17
@@ -3,31 +3,31 @@
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/imprint</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/impressum</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -47,7 +47,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -67,7 +67,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/fr/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -87,7 +87,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/es/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -107,7 +107,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pt-BR/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -127,7 +127,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ru/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -147,7 +147,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/it/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -167,7 +167,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pl/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -187,7 +187,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/tr/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -207,7 +207,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/nl/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -227,7 +227,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ja/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -247,7 +247,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ko/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -267,7 +267,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pt/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<lastmod>2026-06-23</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "{{SCHEMA_APP_DESC}}",
|
||||
"softwareVersion": "2.4.2",
|
||||
"softwareVersion": "2.4.6",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.4.2",
|
||||
"date": "2026-06-19T09:54:42Z"
|
||||
"version": "2.4.5",
|
||||
"date": "2026-06-23T15:40:16Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user