Fall back to next port on EADDRINUSE; show dialog and update tray URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 10:08:15 -07:00
co-authored by Claude Sonnet 4.6
parent 65e871c16e
commit 6fd858347f
2 changed files with 57 additions and 10 deletions
+13 -2
View File
@@ -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();
+43 -7
View File
@@ -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<void> }>}
*/
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 };