mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02afc193c6 | |||
| 7fc156977a | |||
| 99cb07bc2a | |||
| 77ffda3e42 | |||
| 01bc95e176 | |||
| 7417c21217 |
+34
-46
@@ -8,7 +8,6 @@ let isConnecting = false;
|
||||
let peerId = null; // initialized via getPeerId()
|
||||
let currentRoom = null;
|
||||
let lastPeersJson = null;
|
||||
let heartbeatInterval = null;
|
||||
let currentTabId = null;
|
||||
let currentTabTitle = null; // New: for Smart Matching
|
||||
let logs = [];
|
||||
@@ -101,16 +100,25 @@ let forceSyncAcks = new Set();
|
||||
let forceSyncTimeout = null;
|
||||
|
||||
// --- Storage Utils ---
|
||||
function startHeartbeat() {
|
||||
// Session heartbeats are now handled by the chrome.alarms 'keepAlive' listener
|
||||
// to ensure they survive Service Worker suspension in MV3.
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
/**
|
||||
* Canonical peer data factory. All peer object construction must go through
|
||||
* here to guarantee a consistent shape with predictable null defaults.
|
||||
* @param {object} raw - Raw data from server event or heartbeat payload.
|
||||
* @returns {object} Normalized peer data object.
|
||||
*/
|
||||
function createPeerData(raw) {
|
||||
return {
|
||||
peerId: raw.peerId || null,
|
||||
username: raw.username || null,
|
||||
tabTitle: raw.tabTitle || null,
|
||||
mediaTitle: raw.mediaTitle || null,
|
||||
playbackState: raw.playbackState || null,
|
||||
currentTime: raw.currentTime != null ? raw.currentTime : null,
|
||||
volume: raw.volume != null ? raw.volume : null,
|
||||
muted: raw.muted != null ? raw.muted : null,
|
||||
lastHeartbeat: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
async function getPeerId() {
|
||||
@@ -418,17 +426,13 @@ function addToHistory(action, senderId) {
|
||||
|
||||
// --- Event Handlers ---
|
||||
function handleServerEvent(event, data) {
|
||||
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
addLog(`Joined Room: ${data.roomId}`, 'success');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
|
||||
|
||||
// Start background heartbeat
|
||||
startHeartbeat();
|
||||
|
||||
|
||||
// Inform Website Bridge & Popup
|
||||
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
|
||||
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
|
||||
@@ -519,12 +523,7 @@ 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)) {
|
||||
currentRoom.peers.push({
|
||||
peerId: data.peerId,
|
||||
username: data.username,
|
||||
tabTitle: data.tabTitle,
|
||||
mediaTitle: data.mediaTitle || null
|
||||
});
|
||||
currentRoom.peers.push(createPeerData(data));
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
@@ -540,15 +539,15 @@ function handleServerEvent(event, data) {
|
||||
peer.tabTitle = data.tabTitle;
|
||||
peer.username = data.username;
|
||||
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
|
||||
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
||||
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
|
||||
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
|
||||
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
|
||||
peer.lastHeartbeat = Date.now();
|
||||
} else {
|
||||
// Migration: replace string with object
|
||||
// Migration: replace string peer with normalized object
|
||||
const idx = currentRoom.peers.indexOf(peer);
|
||||
currentRoom.peers[idx] = {
|
||||
peerId: data.peerId,
|
||||
username: data.username,
|
||||
tabTitle: data.tabTitle,
|
||||
mediaTitle: data.mediaTitle || null
|
||||
};
|
||||
currentRoom.peers[idx] = createPeerData(data);
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
@@ -641,23 +640,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
await ensureState();
|
||||
// Calling a chrome API keeps the SW alive in MV3 (Chrome 110+)
|
||||
chrome.storage.session.get('keepAlive', () => {});
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
} else if (currentRoom) {
|
||||
// Redundant heartbeat for active SW state
|
||||
const settings = await getSettings();
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
status: 'heartbeat',
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle
|
||||
});
|
||||
}
|
||||
}, 30000); // every 30s
|
||||
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
@@ -709,7 +692,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
stopHeartbeat();
|
||||
|
||||
updateBadgeStatus();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
@@ -878,6 +861,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
me.tabTitle = currentTabTitle;
|
||||
me.username = settings.username;
|
||||
me.mediaTitle = message.payload.mediaTitle;
|
||||
me.playbackState = message.payload.playbackState;
|
||||
me.currentTime = message.payload.currentTime;
|
||||
me.volume = message.payload.volume;
|
||||
me.muted = message.payload.muted;
|
||||
me.lastHeartbeat = Date.now();
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
duration: video.duration || 0,
|
||||
readyState: video.readyState,
|
||||
muted: video.muted,
|
||||
volume: video.volume,
|
||||
playbackRate: video.playbackRate,
|
||||
url: window.location.href,
|
||||
id: video.id || 'none',
|
||||
@@ -314,7 +315,9 @@
|
||||
payload: {
|
||||
playbackState: video.paused ? 'paused' : 'playing',
|
||||
currentTime: video.currentTime,
|
||||
mediaTitle: mediaTitle
|
||||
mediaTitle: mediaTitle,
|
||||
volume: video.volume,
|
||||
muted: video.muted
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.message.includes('Extension context invalidated')) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.4",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -148,9 +148,6 @@
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
@@ -344,6 +341,11 @@
|
||||
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
|
||||
</div>
|
||||
<div id="logList"></div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div id="appVersion" style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v0.0.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
|
||||
+105
-11
@@ -55,6 +55,12 @@ async function init() {
|
||||
elements.password.value = data.password || '';
|
||||
elements.username.value = data.username || '';
|
||||
elements.filterNoise.checked = data.filterNoise !== false;
|
||||
|
||||
// Set Version Info
|
||||
const versionEl = document.getElementById('appVersion');
|
||||
if (versionEl) {
|
||||
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
|
||||
}
|
||||
|
||||
if (data.useCustomServer) {
|
||||
setServerMode(true);
|
||||
@@ -188,11 +194,56 @@ function updateLastActionUI(state, peers) {
|
||||
elements.lastActionCard.appendChild(grid);
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getVolumeIcon(volume, muted) {
|
||||
if (muted || volume === 0) return '🔇';
|
||||
if (volume < 0.33) return '🔈';
|
||||
if (volume < 0.66) return '🔉';
|
||||
return '🔊';
|
||||
}
|
||||
|
||||
let activePeers = [];
|
||||
let interpolationInterval = null;
|
||||
|
||||
function startInterpolation() {
|
||||
if (interpolationInterval) return;
|
||||
interpolationInterval = setInterval(() => {
|
||||
const timeElements = document.querySelectorAll('.peer-time-display');
|
||||
timeElements.forEach(el => {
|
||||
const peerId = el.dataset.peerId;
|
||||
const peer = activePeers.find(p => p.peerId === peerId);
|
||||
if (peer && peer.playbackState === 'playing' && peer.currentTime != null && peer.lastHeartbeat) {
|
||||
const elapsed = (Date.now() - peer.lastHeartbeat) / 1000;
|
||||
el.textContent = formatTime(peer.currentTime + elapsed);
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updatePeerList(peers) {
|
||||
if (!peers) return;
|
||||
activePeers = peers;
|
||||
if (!interpolationInterval) startInterpolation();
|
||||
|
||||
// UI Throttle: Only re-render if the peer state actually changed
|
||||
const currentPeersJson = JSON.stringify(peers);
|
||||
// UI Throttle: Only re-render if the peer state actually changed (excluding time interpolation)
|
||||
const stateToHash = peers.map(p => ({
|
||||
id: p.peerId,
|
||||
user: p.username,
|
||||
tab: p.tabTitle,
|
||||
media: p.mediaTitle,
|
||||
state: p.playbackState,
|
||||
vol: p.volume,
|
||||
muted: p.muted
|
||||
}));
|
||||
const currentPeersJson = JSON.stringify(stateToHash);
|
||||
if (currentPeersJson === lastPeersJson) return;
|
||||
lastPeersJson = currentPeersJson;
|
||||
|
||||
@@ -213,10 +264,10 @@ function updatePeerList(peers) {
|
||||
|
||||
const peerItem = document.createElement('div');
|
||||
peerItem.className = 'peer-item';
|
||||
peerItem.style.cssText = 'display:block; padding: 6px 0;';
|
||||
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
if (pUsername) {
|
||||
@@ -235,29 +286,72 @@ function updatePeerList(peers) {
|
||||
|
||||
header.appendChild(nameSpan);
|
||||
|
||||
// Volume Icon (Top Right)
|
||||
if (p.volume !== undefined && p.volume !== null) {
|
||||
const volIcon = document.createElement('div');
|
||||
volIcon.style.cssText = 'position:absolute; top:8px; right:0; cursor:help; font-size:14px;';
|
||||
volIcon.textContent = getVolumeIcon(p.volume, p.muted);
|
||||
volIcon.title = p.muted ? 'Muted' : `Volume: ${Math.round(p.volume * 100)}%`;
|
||||
peerItem.appendChild(volIcon);
|
||||
}
|
||||
|
||||
if (pId === localPeerId) {
|
||||
const you = document.createElement('span');
|
||||
you.style.cssText = 'font-size:10px; color:var(--accent)';
|
||||
you.style.cssText = 'font-size:10px; color:var(--accent); font-weight:bold;';
|
||||
you.textContent = 'YOU';
|
||||
header.appendChild(you);
|
||||
}
|
||||
|
||||
peerItem.appendChild(header);
|
||||
|
||||
// Media Info
|
||||
if (p.mediaTitle) {
|
||||
const mediaDiv = document.createElement('div');
|
||||
mediaDiv.style.cssText = 'font-size:11px; color:var(--star); font-weight: 600; margin-top: 2px;';
|
||||
mediaDiv.style.cssText = 'font-size:11px; color:var(--star); font-weight: 600; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px;';
|
||||
mediaDiv.textContent = `🎬 ${p.mediaTitle}`;
|
||||
peerItem.appendChild(mediaDiv);
|
||||
}
|
||||
|
||||
if (pTabTitle) {
|
||||
const titleDiv = document.createElement('div');
|
||||
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted); opacity: 0.8;';
|
||||
titleDiv.textContent = p.mediaTitle ? `via ${pTabTitle}` : pTabTitle;
|
||||
peerItem.appendChild(titleDiv);
|
||||
// Status Line (Play/Pause + Time)
|
||||
const statusLine = document.createElement('div');
|
||||
statusLine.style.cssText = 'display:flex; align-items:center; gap:8px; margin-top:4px;';
|
||||
|
||||
if (p.playbackState) {
|
||||
const stateIcon = document.createElement('span');
|
||||
stateIcon.style.fontSize = '10px';
|
||||
if (p.playbackState === 'playing') {
|
||||
stateIcon.textContent = '▶';
|
||||
stateIcon.style.color = 'var(--success)';
|
||||
} else {
|
||||
stateIcon.textContent = '⏸';
|
||||
stateIcon.style.color = 'var(--error)';
|
||||
}
|
||||
statusLine.appendChild(stateIcon);
|
||||
}
|
||||
|
||||
if (p.currentTime !== undefined && p.currentTime !== null) {
|
||||
const timeSpan = document.createElement('span');
|
||||
timeSpan.className = 'peer-time-display';
|
||||
timeSpan.dataset.peerId = pId;
|
||||
timeSpan.style.cssText = 'font-size:11px; font-family:monospace; color:var(--text-muted);';
|
||||
|
||||
let displayTime = p.currentTime;
|
||||
if (p.playbackState === 'playing' && p.lastHeartbeat && p.currentTime != null) {
|
||||
const elapsed = (Date.now() - p.lastHeartbeat) / 1000;
|
||||
displayTime += elapsed;
|
||||
}
|
||||
timeSpan.textContent = formatTime(displayTime);
|
||||
statusLine.appendChild(timeSpan);
|
||||
}
|
||||
|
||||
if (pTabTitle) {
|
||||
const titleDiv = document.createElement('span');
|
||||
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted); opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: right;';
|
||||
titleDiv.textContent = pTabTitle;
|
||||
statusLine.appendChild(titleDiv);
|
||||
}
|
||||
|
||||
peerItem.appendChild(statusLine);
|
||||
container.appendChild(peerItem);
|
||||
});
|
||||
};
|
||||
|
||||
+105
-64
@@ -23,7 +23,7 @@ const httpServer = createServer(app);
|
||||
// Socket.IO setup with security constraints
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
origin: ["https://koalasync.shik3i.net"],
|
||||
methods: ["GET", "POST"]
|
||||
},
|
||||
maxHttpBufferSize: 1024, // 1KB max per message
|
||||
@@ -113,6 +113,51 @@ function checkEventRate(socketId) {
|
||||
return entry.count <= 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central peer teardown. Removes a socket from all room state and notifies
|
||||
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
||||
*
|
||||
* @param {string} socketId - The socket.id being removed.
|
||||
* @param {string} roomId - The room it belongs to.
|
||||
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
|
||||
* @param {boolean} [emitLeave=true] - Set false when the socket.io room leave
|
||||
* is handled by the caller (e.g. reaper calls
|
||||
* socket.leave() before us, or dedupe calls
|
||||
* oldSocket.leave() before disconnecting).
|
||||
*/
|
||||
function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const peerData = room.peerData.get(socketId);
|
||||
if (!peerData) return; // Already cleaned up
|
||||
|
||||
const { peerId } = peerData;
|
||||
|
||||
// 1. Remove from room data structures
|
||||
room.peers.delete(socketId);
|
||||
room.peerIds.delete(socketId);
|
||||
room.peerData.delete(socketId);
|
||||
|
||||
// 2. Remove from global maps
|
||||
socketToRoom.delete(socketId);
|
||||
if (peerToSocket.get(peerId) === socketId) {
|
||||
peerToSocket.delete(peerId);
|
||||
}
|
||||
|
||||
// 3. Notify remaining peers (use io.to so the removed socket itself
|
||||
// doesn't receive it — it has already left or is disconnecting)
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
|
||||
// 4. Delete empty room
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
log('ROOM', `Deleted empty room after ${reason}: ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
|
||||
log('ROOM', `Peer ${peerId} removed (${reason}) from room ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const clientIp = socket.handshake.address;
|
||||
|
||||
@@ -155,7 +200,16 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload.roomId !== 'string') return;
|
||||
const { roomId, password, peerId, username, tabTitle, mediaTitle, protocolVersion } = payload;
|
||||
const { password, peerId, protocolVersion } = payload;
|
||||
|
||||
// --- M-2: Sanitize and clamp all string fields ---
|
||||
const roomId = String(payload.roomId || '').substring(0, 64);
|
||||
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
|
||||
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
|
||||
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
|
||||
|
||||
if (!roomId) return; // Guard: empty after sanitization
|
||||
|
||||
try {
|
||||
// Protocol check
|
||||
if (protocolVersion !== '1.0.0') {
|
||||
@@ -171,14 +225,7 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
if (oldMapping && oldMapping.roomId !== roomId) {
|
||||
socket.leave(oldMapping.roomId);
|
||||
const oldRoom = rooms.get(oldMapping.roomId);
|
||||
if (oldRoom) {
|
||||
oldRoom.peers.delete(socket.id);
|
||||
oldRoom.peerIds.delete(socket.id);
|
||||
oldRoom.peerData.delete(socket.id);
|
||||
socket.to(oldMapping.roomId).emit(EVENTS.PEER_STATUS, { peerId: oldMapping.peerId, status: 'left' });
|
||||
if (oldRoom.peers.size === 0) rooms.delete(oldMapping.roomId);
|
||||
}
|
||||
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
|
||||
}
|
||||
|
||||
const ip = socket.handshake.address;
|
||||
@@ -228,11 +275,7 @@ io.on('connection', (socket) => {
|
||||
oldSocket.disconnect(true);
|
||||
log('DEDUPE', `Kicked old session for peer ${peerId}`);
|
||||
}
|
||||
room.peers.delete(sid);
|
||||
room.peerIds.delete(sid);
|
||||
room.peerData.delete(sid);
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
|
||||
log('ROOM', `Deduplicated peer ${peerId} from room ${roomId}`);
|
||||
removePeerFromRoom(sid, roomId, 'dedupe');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,12 +329,19 @@ io.on('connection', (socket) => {
|
||||
room.lastActivity = Date.now();
|
||||
|
||||
// Update peer metadata and lastSeen
|
||||
// Sanitize mutable string fields to enforce the same length
|
||||
// limits as JOIN_ROOM — the relay path is otherwise unbounded.
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : val;
|
||||
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
|
||||
room.peerData.set(socket.id, {
|
||||
...existing,
|
||||
username: data.username !== undefined ? data.username : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? data.tabTitle : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? data.mediaTitle : existing.mediaTitle,
|
||||
username: data.username !== undefined ? clamp(data.username, 30) : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? clamp(data.tabTitle, 100) : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? clamp(data.mediaTitle, 100) : existing.mediaTitle,
|
||||
playbackState: data.playbackState !== undefined ? data.playbackState : existing.playbackState,
|
||||
currentTime: data.currentTime !== undefined ? data.currentTime : existing.currentTime,
|
||||
volume: data.volume !== undefined ? data.volume : existing.volume,
|
||||
muted: data.muted !== undefined ? data.muted : existing.muted,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
|
||||
@@ -313,23 +363,8 @@ io.on('connection', (socket) => {
|
||||
socket.on(EVENTS.LEAVE_ROOM, () => {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const { roomId, peerId } = mapping;
|
||||
socket.leave(roomId);
|
||||
const room = rooms.get(roomId);
|
||||
if (room) {
|
||||
room.peers.delete(socket.id);
|
||||
room.peerIds.delete(socket.id);
|
||||
room.peerData.delete(socket.id);
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
log('ROOM', `Deleted empty room: ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
}
|
||||
socketToRoom.delete(socket.id);
|
||||
if (peerToSocket.get(peerId) === socket.id) {
|
||||
peerToSocket.delete(peerId);
|
||||
}
|
||||
socket.leave(mapping.roomId);
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -355,22 +390,10 @@ io.on('connection', (socket) => {
|
||||
eventCounts.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const { roomId, peerId } = mapping;
|
||||
const room = rooms.get(roomId);
|
||||
if (room) {
|
||||
room.peers.delete(socket.id);
|
||||
room.peerIds.delete(socket.id);
|
||||
room.peerData.delete(socket.id);
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
log('ROOM', `Deleted empty room (after disconnect): ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
}
|
||||
socketToRoom.delete(socket.id);
|
||||
if (peerToSocket.get(peerId) === socket.id) {
|
||||
peerToSocket.delete(peerId);
|
||||
}
|
||||
// Socket is already disconnected — no need to call socket.leave().
|
||||
// removePeerFromRoom uses io.to() for notifications, which correctly
|
||||
// excludes this dead socket since it has already left all rooms.
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -383,23 +406,21 @@ setInterval(() => {
|
||||
|
||||
for (const [roomId, room] of rooms) {
|
||||
// 1. Prune dead peers
|
||||
// Snapshot keys first — we must not mutate peerData while iterating it.
|
||||
const staleSids = [];
|
||||
for (const [sid, data] of room.peerData.entries()) {
|
||||
if (data.lastSeen && data.lastSeen < peerCutoff) {
|
||||
const socket = io.sockets.sockets.get(sid);
|
||||
if (socket) socket.leave(roomId);
|
||||
|
||||
room.peers.delete(sid);
|
||||
room.peerIds.delete(sid);
|
||||
room.peerData.delete(sid);
|
||||
socketToRoom.delete(sid);
|
||||
if (peerToSocket.get(data.peerId) === sid) {
|
||||
peerToSocket.delete(data.peerId);
|
||||
}
|
||||
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
|
||||
log('CLEANUP', `Pruned dead peer ${data.peerId} from room ${roomId}`);
|
||||
staleSids.push(sid);
|
||||
}
|
||||
}
|
||||
for (const sid of staleSids) {
|
||||
// Gracefully evict the socket from the Socket.IO room if it is
|
||||
// still technically connected (zombie with no heartbeat).
|
||||
const deadSocket = io.sockets.sockets.get(sid);
|
||||
if (deadSocket) deadSocket.leave(roomId);
|
||||
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
}
|
||||
|
||||
// 2. Prune empty or inactive rooms
|
||||
if (room.peers.size === 0 || room.lastActivity < roomCutoff) {
|
||||
@@ -413,3 +434,23 @@ setInterval(() => {
|
||||
httpServer.listen(PORT, () => {
|
||||
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// --- M-4: Graceful Shutdown ---
|
||||
function gracefulShutdown(signal) {
|
||||
log('SERVER', `${signal} received — starting graceful shutdown...`);
|
||||
// 1. Notify all connected clients so they can display a meaningful message
|
||||
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Please reconnect in a moment.' });
|
||||
// 2. Stop accepting new HTTP connections
|
||||
httpServer.close(() => {
|
||||
log('SERVER', 'HTTP server closed. Exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
// 3. Safety net: force-exit after 5s if connections don't drain
|
||||
setTimeout(() => {
|
||||
log('SERVER', 'Force-exit after timeout.');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.1.2";
|
||||
export const APP_VERSION = "1.1.4";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
||||
|
||||
+25
-7
@@ -104,12 +104,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'invite-banner';
|
||||
banner.id = 'koala-banner';
|
||||
banner.innerHTML = `
|
||||
<div class="container" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>🎫 Invitation for <b>${roomId}</b> detected!</span>
|
||||
<a href="join.html${window.location.hash}" class="btn-banner">OPEN JOIN PAGE</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'container';
|
||||
container.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
|
||||
|
||||
const inviteSpan = document.createElement('span');
|
||||
inviteSpan.appendChild(document.createTextNode('🎫 Invitation for '));
|
||||
const boldRoom = document.createElement('b');
|
||||
boldRoom.textContent = roomId;
|
||||
inviteSpan.appendChild(boldRoom);
|
||||
inviteSpan.appendChild(document.createTextNode(' detected!'));
|
||||
|
||||
const joinLink = document.createElement('a');
|
||||
joinLink.href = 'join.html' + window.location.hash;
|
||||
joinLink.className = 'btn-banner';
|
||||
joinLink.textContent = 'OPEN JOIN PAGE';
|
||||
|
||||
container.appendChild(inviteSpan);
|
||||
container.appendChild(joinLink);
|
||||
banner.appendChild(container);
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +192,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => window.close(), 3000);
|
||||
} else {
|
||||
banner.style.background = 'var(--error)';
|
||||
banner.innerHTML = `<div class="container">❌ Error: ${message}</div>`;
|
||||
banner.innerHTML = '';
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'container';
|
||||
errDiv.textContent = '❌ Error: ' + message;
|
||||
banner.appendChild(errDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user