Compare commits

...

6 Commits

Author SHA1 Message Date
KoalaDev aa84b63c77 docs: add v2.4.5 entry to CHANGELOG.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:38:36 +02:00
KoalaDev bd60a14754 feat(extension): send nothing to the server while alone in a room
When no other peer is present, 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):
- keepAlive heartbeat gated on a live peer count
- force-sync is a no-op when solo (no pause-then-timeout freeze, no traffic)
- episode auto-sync skips the lobby entirely and just plays the next episode

The solo state is recomputed live from the current peer list on every event
(never cached), so the instant another peer joins syncing resumes. On that
solo->not-solo transition the background also requests an immediate heartbeat
from the content script so the newcomer sees the current position without
waiting for the next interval.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:38:36 +02:00
KoalaDev a1bdcf4325 fix(extension): stop storing room/settings in chrome.storage.sync
Room ID, password, and username were 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 (popup, content, audio-options,
applyAudioSettingsToTab) are now local-only, and legacy keys are actively
purged from sync on install/update/startup. Only onboardingComplete and
dismissedHints remain in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:38:36 +02:00
KoalaDev b846803062 Potential fix for code scanning alert no. 14: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-23 00:56:28 +02:00
KoalaDev 4bc7ad365d docs: add v2.4.4 entry to CHANGELOG.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:49:42 +02:00
GitHub Action 68b2205b0d chore(release): update versions to v2.4.4 [skip ci] 2026-06-22 22:46:03 +00:00
13 changed files with 128 additions and 104 deletions
+3
View File
@@ -8,6 +8,9 @@ on:
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:
+2 -2
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-v2.4.3-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.4-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.3 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.4.4 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
+18
View File
@@ -4,6 +4,24 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.4.5] — 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
+4 -8
View File
@@ -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();
}
+67 -41
View File
@@ -43,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 ---
@@ -286,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 {
@@ -320,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(),
@@ -1081,13 +1081,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 });
}
@@ -1450,14 +1458,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
});
}
}
}
});
@@ -1494,14 +1506,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
@@ -1712,6 +1718,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 });
@@ -1755,11 +1774,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;
@@ -1919,6 +1935,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)) {
+9 -18
View File
@@ -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;
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.4.3",
"version": "2.4.4",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
+2 -12
View File
@@ -175,19 +175,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) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "2.4.3",
"version": "2.4.4",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "2.4.3";
export const APP_VERSION = "2.4.4";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
+17 -17
View File
@@ -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-22</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-22</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-22</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-22</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/</loc>
<lastmod>2026-06-19</lastmod>
<lastmod>2026-06-22</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-22</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-22</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-22</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-22</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-22</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-22</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-22</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-22</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-22</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-22</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-22</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-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
+1 -1
View File
@@ -106,7 +106,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "2.4.3",
"softwareVersion": "2.4.4",
"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": "2.4.3",
"date": "2026-06-19T14:39:05Z"
"version": "2.4.4",
"date": "2026-06-22T22:46:03Z"
}