From 6fd858347fd7a60516953cbbc76c64c08fc754f4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 26 Apr 2026 10:08:15 -0700 Subject: [PATCH] Fall back to next port on EADDRINUSE; show dialog and update tray URL Co-Authored-By: Claude Sonnet 4.6 --- desktop-client/index.js | 15 ++++++++++-- desktop-client/server.js | 52 +++++++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/desktop-client/index.js b/desktop-client/index.js index f93e8b4..8f63507 100644 --- a/desktop-client/index.js +++ b/desktop-client/index.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const path = require('path'); const { startServer } = require('./server'); const { createTray } = require('./tray'); @@ -40,6 +40,17 @@ async function createWindow() { app.whenReady().then(async () => { serverHandle = await startServer({ port: PORT, overlayDir: OVERLAY_DIR }); + if (serverHandle.port !== PORT) { + const actual = serverHandle.port; + await dialog.showMessageBox({ + type: 'info', + title: 'Port in use', + message: `Port ${PORT} was in use; using ${actual} instead.`, + detail: `If you have an OBS browser source pointed at this app, update it to http://localhost:${actual}/`, + buttons: ['OK'], + }); + } + ipcMain.on('wtm:state', (_event, payload) => { const next = serverHandle.setState(payload || { isPlaying: false }); if (tray) tray.update({ title: next.title, artist: next.artist }); @@ -48,7 +59,7 @@ app.whenReady().then(async () => { await createWindow(); tray = createTray({ - port: PORT, + port: serverHandle.port, onShow: () => { if (!mainWindow) return; if (mainWindow.isMinimized()) mainWindow.restore(); diff --git a/desktop-client/server.js b/desktop-client/server.js index eb0c8da..30c89e9 100644 --- a/desktop-client/server.js +++ b/desktop-client/server.js @@ -11,7 +11,7 @@ const { initialState, mergeState } = require('./state'); * @param {string} [opts.overlayDir] Absolute path to the overlay/ directory to serve as static files. Optional (omit for tests). * @returns {Promise<{ setState: (update: object) => object, stop: () => Promise }>} */ -function startServer({ port, overlayDir }) { +async function startServer({ port, overlayDir }) { let currentState = { ...initialState }; const app = express(); @@ -82,13 +82,49 @@ function startServer({ port, overlayDir }) { }); } - return new Promise((resolve, reject) => { - server.listen(port, '127.0.0.1', () => { - console.log(`[wtm] server listening on http://127.0.0.1:${port}`); - resolve({ setState, stop }); - }); - server.on('error', reject); - }); + // Try the preferred port first, then up to 4 sequential fallbacks on EADDRINUSE. + const MAX_PORT_ATTEMPTS = 5; + let actualPort = null; + let lastErr = null; + + // The WebSocketServer re-emits server 'error' events on itself. + // Add a no-op sink so Node doesn't crash on unhandled 'error' during port scanning. + const wssErrorSink = () => {}; + wss.on('error', wssErrorSink); + + for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) { + const tryPort = port + i; + try { + await new Promise((resolve, reject) => { + const onError = (err) => { + server.removeListener('listening', onListening); + reject(err); + }; + const onListening = () => { + server.removeListener('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(tryPort, '127.0.0.1'); + }); + actualPort = tryPort; + break; + } catch (err) { + lastErr = err; + if (err.code !== 'EADDRINUSE') throw err; + // EADDRINUSE — fall through and try the next port. + } + } + + wss.removeListener('error', wssErrorSink); + + if (actualPort === null) { + throw lastErr || new Error(`No free port in range ${port}..${port + MAX_PORT_ATTEMPTS - 1}`); + } + + console.log(`[wtm] server listening on http://127.0.0.1:${actualPort}`); + return { setState, stop, port: actualPort }; } module.exports = { startServer };