loukai-app 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +9 -3
  2. package/package.json +5 -3
  3. package/scripts/ensure-electron.js +208 -0
  4. package/src/main/creator/conversionService.js +1 -1
  5. package/src/main/creator/llmService.js +62 -10
  6. package/src/main/handlers/creatorHandlers.js +4 -2
  7. package/src/main/handlers/index.js +5 -0
  8. package/src/main/handlers/streamingHandlers.js +70 -0
  9. package/src/main/preload.js +31 -0
  10. package/src/main/webServer.js +77 -1
  11. package/src/renderer/components/AppRoot.jsx +4 -0
  12. package/src/renderer/dist/assets/{kaiPlayer-DSaY7TxC.js → kaiPlayer-BsM-WzYQ.js} +2 -2
  13. package/src/renderer/dist/assets/kaiPlayer-BsM-WzYQ.js.map +1 -0
  14. package/src/renderer/dist/assets/streamingSender-HwIev870.js +2 -0
  15. package/src/renderer/dist/assets/streamingSender-HwIev870.js.map +1 -0
  16. package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js +2 -0
  17. package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js.map +1 -0
  18. package/src/renderer/dist/renderer.js +15 -15
  19. package/src/renderer/dist/renderer.js.map +1 -1
  20. package/src/renderer/js/kaiPlayer.js +15 -0
  21. package/src/renderer/js/streamingSender.js +292 -0
  22. package/src/renderer/js/webrtcManager.js +3 -0
  23. package/src/shared/components/PlayerControls.jsx +12 -0
  24. package/src/shared/hooks/useStreamingSender.js +30 -0
  25. package/src/web/App.jsx +1 -0
  26. package/src/web/dist/assets/index-DUPLO3h6.js +11 -0
  27. package/src/web/dist/assets/{index-CGbmW1VG.js.map → index-DUPLO3h6.js.map} +1 -1
  28. package/src/web/dist/index.html +1 -1
  29. package/static/viewer.html +421 -0
  30. package/src/renderer/dist/assets/kaiPlayer-DSaY7TxC.js.map +0 -1
  31. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +0 -2
  32. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +0 -1
  33. package/src/web/dist/assets/index-CGbmW1VG.js +0 -11
package/README.md CHANGED
@@ -11,6 +11,12 @@
11
11
 
12
12
  Loukai is a free, open source karaoke software that runs locally on your computer to **play** and **create** karaoke files from your own music. Built on M4A Stems (MPEG-4 multi-track audio), it uses industry-standard formats compatible with DJ software, giving you full control over your personal karaoke library.
13
13
 
14
+ ## Quick start (with nodejs installed)
15
+
16
+ ```
17
+ npx loukai-app
18
+ ```
19
+
14
20
  **Key highlights:**
15
21
  - **Open Format**: Built on NI Stems — no vendor lock-in, works with Traktor, Mixxx, and other DJ software
16
22
  - **Create Your Own**: Built-in Creator processes your audio files into stem-separated karaoke with AI-transcribed lyrics
@@ -24,7 +30,7 @@ Loukai is a free, open source karaoke software that runs locally on your compute
24
30
  ## Features
25
31
 
26
32
  ### Audio & Playback
27
- - **M4A Stems Format (Primary)**: Built on [NI Stems](https://www.native-instruments.com/en/specials/stems/) with karaoke extensions
33
+ - **MP4 Stems Format (Primary)**: Built on [NI Stems](https://www.native-instruments.com/en/specials/stems/) with karaoke extensions
28
34
  - Compatible with DJ software (Traktor, Mixxx) via standard NI Stems metadata
29
35
  - Smaller file sizes than legacy formats
30
36
  - Embedded lyrics with word-level timing in custom atoms
@@ -51,7 +57,7 @@ Loukai is a free, open source karaoke software that runs locally on your compute
51
57
 
52
58
  ### Library & Search
53
59
  - **Fast Library Scanning**: Automatic metadata extraction from thousands of songs
54
- - **M4A Stems Native**: Optimized for MPEG-4 multi-track audio with full metadata support
60
+ - **MP4 Stems Native**: Optimized for MPEG-4 multi-track audio with full metadata support
55
61
  - **Legacy Format Support**: Also reads CDG/MP3 pairs
56
62
  - **Smart Search**: Fuzzy search across titles, artists, and albums
57
63
  - **Alphabet Navigation**: Quick filtering by first letter
@@ -194,7 +200,7 @@ Loukai is built with a multi-process architecture:
194
200
  - **Library Scanner**: Metadata extraction and caching
195
201
  - **Web Server**: Express 5 REST API + Socket.IO
196
202
  - **State Management**: Centralized app state with event emitters
197
- - **File System**: M4A/CDG file parsing and manipulation
203
+ - **File System**: MP4/CDG file parsing and manipulation
198
204
  - **IPC Handlers**: Communication bridge to renderer
199
205
 
200
206
  ### Renderer Process (React)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loukai-app",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Loukai Karaoke - Free and open source karaoke system for playing and creating stem-based karaoke files",
6
6
  "main": "src/main/main.js",
@@ -16,6 +16,7 @@
16
16
  "build:win": "npm run build:all && electron-builder --win && node scripts/rename-artifacts.js",
17
17
  "build:mac": "npm run build:all && electron-builder --mac && node scripts/rename-artifacts.js",
18
18
  "rebuild": "electron-rebuild",
19
+ "postinstall": "node scripts/ensure-electron.js",
19
20
  "test": "vitest",
20
21
  "test:ui": "vitest --ui",
21
22
  "test:run": "vitest run",
@@ -46,6 +47,7 @@
46
47
  "@vitest/coverage-v8": "^3.2.4",
47
48
  "@vitest/ui": "^3.2.4",
48
49
  "autoprefixer": "^10.4.21",
50
+ "electron": "^40.6.0",
49
51
  "electron-builder": "^26.0.12",
50
52
  "eslint": "^9.37.0",
51
53
  "eslint-config-prettier": "^10.1.8",
@@ -74,7 +76,6 @@
74
76
  "cdgraphics": "^7.0.0",
75
77
  "cookie-session": "^2.1.1",
76
78
  "cors": "^2.8.5",
77
- "electron": "^40.6.0",
78
79
  "express": "^5.1.0",
79
80
  "express-rate-limit": "^8.1.0",
80
81
  "fuse.js": "^7.1.0",
@@ -236,6 +237,7 @@
236
237
  "src/",
237
238
  "public/",
238
239
  "bin/",
239
- "static/"
240
+ "static/",
241
+ "scripts/ensure-electron.js"
240
242
  ]
241
243
  }
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Ensure Electron is installed AND correctly extracted.
4
+ *
5
+ * Electron lives in `devDependencies` because electron-builder requires it
6
+ * there (and bundles its own copy into the DMG/installer — Electron in
7
+ * `dependencies`/`optionalDependencies` makes electron-builder copy the whole
8
+ * ~200MB Electron package into the app on top of the framework). But a
9
+ * production/npx install skips devDependencies, so Electron is absent at
10
+ * runtime. `postinstall` still runs for those installs, so this bridges the gap
11
+ * with two jobs:
12
+ *
13
+ * 1. INSTALL: if Electron is missing (production/npx consumer), install it from
14
+ * the version range declared in our own package.json. In the dev repo
15
+ * Electron is already present, so this never triggers.
16
+ *
17
+ * 2. REPAIR: Electron's own postinstall extracts its binary with `extract-zip`
18
+ * (bundling the unmaintained `yauzl@2.x`). On Node 24 that extractor
19
+ * silently stalls after the first zip entry, leaving a half-written `dist/`
20
+ * (only `LICENSES.chromium.html`, no `path.txt`). We re-extract the
21
+ * downloaded zip with the system archive tool (`unzip` on macOS/Linux,
22
+ * PowerShell on Windows), which is unaffected by the bug, then write
23
+ * `path.txt`.
24
+ *
25
+ * When Electron is already present and healthy, this is a silent no-op.
26
+ * Best-effort throughout: it never fails the install; if it can't finish it
27
+ * logs actionable guidance and exits 0.
28
+ */
29
+
30
+ import { createRequire } from 'module';
31
+ import { execFileSync } from 'child_process';
32
+ import {
33
+ existsSync,
34
+ readFileSync,
35
+ writeFileSync,
36
+ renameSync,
37
+ rmSync,
38
+ mkdirSync,
39
+ } from 'fs';
40
+ import { join, dirname } from 'path';
41
+ import { fileURLToPath } from 'url';
42
+
43
+ const require = createRequire(import.meta.url);
44
+ const here = dirname(fileURLToPath(import.meta.url));
45
+ const projectDir = join(here, '..'); // package root (postinstall cwd may vary)
46
+
47
+ function log(msg) {
48
+ console.log(`[ensure-electron] ${msg}`);
49
+ }
50
+
51
+ function platformBinaryPath(platform) {
52
+ switch (platform) {
53
+ case 'mas':
54
+ case 'darwin':
55
+ return 'Electron.app/Contents/MacOS/Electron';
56
+ case 'freebsd':
57
+ case 'openbsd':
58
+ case 'linux':
59
+ return 'electron';
60
+ case 'win32':
61
+ return 'electron.exe';
62
+ default:
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function resolveElectronDir() {
68
+ try {
69
+ return dirname(require.resolve('electron/package.json', { paths: [projectDir] }));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function declaredElectronRange() {
76
+ try {
77
+ const pkg = require(join(projectDir, 'package.json'));
78
+ return (
79
+ (pkg.devDependencies && pkg.devDependencies.electron) ||
80
+ (pkg.dependencies && pkg.dependencies.electron) ||
81
+ null
82
+ );
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function isInstalled(electronDir, version, platformPath) {
89
+ try {
90
+ const distVersion = readFileSync(join(electronDir, 'dist', 'version'), 'utf-8').replace(/^v/, '');
91
+ if (distVersion !== version) return false;
92
+ if (readFileSync(join(electronDir, 'path.txt'), 'utf-8') !== platformPath) return false;
93
+ } catch {
94
+ return false;
95
+ }
96
+ return existsSync(join(electronDir, 'dist', platformPath));
97
+ }
98
+
99
+ function extractZip(zipPath, destDir) {
100
+ rmSync(destDir, { recursive: true, force: true });
101
+ mkdirSync(destDir, { recursive: true });
102
+ if (process.platform === 'win32') {
103
+ execFileSync(
104
+ 'powershell',
105
+ ['-NoProfile', '-NonInteractive', '-Command', `Expand-Archive -Path "${zipPath}" -DestinationPath "${destDir}" -Force`],
106
+ { stdio: 'ignore' }
107
+ );
108
+ } else {
109
+ // `unzip` ships with macOS and virtually every Linux base image.
110
+ execFileSync('unzip', ['-q', '-o', zipPath, '-d', destDir], { stdio: 'ignore' });
111
+ }
112
+ }
113
+
114
+ function installElectron(range) {
115
+ const spec = `electron@${range || 'latest'}`;
116
+ log(`Electron not found; installing ${spec} (devDependency is skipped by production/npx installs)…`);
117
+ // --no-save: don't touch package.json; install into this package's node_modules.
118
+ execFileSync(
119
+ 'npm',
120
+ ['install', spec, '--no-save', '--no-audit', '--no-fund', '--loglevel', 'error'],
121
+ { cwd: projectDir, stdio: 'inherit', env: { ...process.env, npm_config_save: 'false' } }
122
+ );
123
+ }
124
+
125
+ async function main() {
126
+ if (process.env.ELECTRON_SKIP_BINARY_DOWNLOAD) {
127
+ return; // user opted out of the binary entirely
128
+ }
129
+
130
+ const platform = process.env.npm_config_platform || process.platform;
131
+ const arch = process.env.npm_config_arch || process.arch;
132
+ const platformPath = platformBinaryPath(platform);
133
+ if (!platformPath) {
134
+ log(`Unsupported platform "${platform}"; leaving Electron untouched.`);
135
+ return;
136
+ }
137
+
138
+ // ---- Job 1: ensure the Electron package is present ----
139
+ let electronDir = resolveElectronDir();
140
+ if (electronDir == null) {
141
+ const range = declaredElectronRange();
142
+ if (range == null) {
143
+ return; // we don't declare Electron at all — nothing to do
144
+ }
145
+ try {
146
+ installElectron(range);
147
+ } catch (err) {
148
+ log(`Could not install Electron automatically: ${err.message}`);
149
+ log('Install it manually with `npm install electron`, then re-run.');
150
+ return;
151
+ }
152
+ electronDir = resolveElectronDir();
153
+ if (electronDir == null) {
154
+ log('Electron still not resolvable after install; aborting (best-effort).');
155
+ return;
156
+ }
157
+ }
158
+
159
+ const { version } = require(join(electronDir, 'package.json'));
160
+
161
+ // ---- Job 2: ensure the binary is actually extracted ----
162
+ if (isInstalled(electronDir, version, platformPath)) {
163
+ return; // healthy — silent no-op (the common dev-repo case)
164
+ }
165
+
166
+ log(`Electron ${version} is not fully extracted; repairing (Node ${process.version} extract-zip workaround)…`);
167
+
168
+ let zipPath;
169
+ try {
170
+ const getRequire = createRequire(join(electronDir, 'package.json'));
171
+ const { downloadArtifact } = getRequire('@electron/get');
172
+ let checksums;
173
+ try {
174
+ checksums = getRequire(join(electronDir, 'checksums.json'));
175
+ } catch {
176
+ checksums = undefined;
177
+ }
178
+ zipPath = await downloadArtifact({ version, artifactName: 'electron', platform, arch, checksums });
179
+ } catch (err) {
180
+ log(`Could not obtain the Electron zip: ${err.message}`);
181
+ log('Run `npm rebuild electron` (Node 22 or earlier), or reinstall, to fix.');
182
+ return;
183
+ }
184
+
185
+ const distDir = join(electronDir, 'dist');
186
+ try {
187
+ extractZip(zipPath, distDir);
188
+ const srcTypeDef = join(distDir, 'electron.d.ts');
189
+ if (existsSync(srcTypeDef)) {
190
+ renameSync(srcTypeDef, join(electronDir, 'electron.d.ts'));
191
+ }
192
+ writeFileSync(join(electronDir, 'path.txt'), platformPath);
193
+ } catch (err) {
194
+ log(`Extraction failed: ${err.message}`);
195
+ log('Ensure `unzip` (macOS/Linux) or PowerShell (Windows) is available, then reinstall.');
196
+ return;
197
+ }
198
+
199
+ if (isInstalled(electronDir, version, platformPath)) {
200
+ log('Electron ready.');
201
+ } else {
202
+ log('Repair did not produce a valid install; try `npm rebuild electron`.');
203
+ }
204
+ }
205
+
206
+ main().catch((err) => {
207
+ log(`Unexpected error (ignored): ${err.message}`);
208
+ });
@@ -287,7 +287,7 @@ export async function runConversion(
287
287
  let llmStats = null;
288
288
  if (settingsManager && referenceLyrics) {
289
289
  try {
290
- const llmSettings = llmService.getLLMSettings(settingsManager);
290
+ const llmSettings = llmService.getLLMSettingsRaw(settingsManager);
291
291
  // Local LLM (lmstudio) doesn't require API key
292
292
  const hasValidConfig = llmSettings.provider === 'lmstudio' || llmSettings.apiKey;
293
293
  if (llmSettings.enabled && hasValidConfig) {
@@ -297,13 +297,49 @@ function parseCorrection(llmResponse, originalOutput) {
297
297
  }
298
298
 
299
299
  /**
300
- * Get LLM settings from app settings
301
- * Uses unified defaults from shared/defaults.js
300
+ * Whether an API key value is a masked placeholder (contains the bullet char
301
+ * used by getLLMSettings) rather than a real key. Real keys are ASCII, so any
302
+ * occurrence of '•' (U+2022) means it came from the masked, renderer-facing
303
+ * settings and must never be used as a real key or persisted.
302
304
  */
303
- export function getLLMSettings(settingsManager) {
305
+ function isMaskedApiKey(key) {
306
+ return typeof key === 'string' && key.includes('•');
307
+ }
308
+
309
+ /**
310
+ * Read the real (unmasked) stored API key.
311
+ */
312
+ function getStoredApiKey(settingsManager) {
313
+ const llmConfig = settingsManager.get('creator.llm', {});
314
+ return llmConfig.apiKey || LLM_DEFAULTS.apiKey || '';
315
+ }
316
+
317
+ /**
318
+ * Get LLM settings with the REAL API key, for internal main-process use
319
+ * (actual API calls). Never send this to the renderer.
320
+ */
321
+ export function getLLMSettingsRaw(settingsManager) {
304
322
  const llmConfig = settingsManager.get('creator.llm', {});
305
323
  const apiKey = llmConfig.apiKey || LLM_DEFAULTS.apiKey;
306
324
 
325
+ return {
326
+ enabled: llmConfig.enabled ?? LLM_DEFAULTS.enabled,
327
+ provider: llmConfig.provider || LLM_DEFAULTS.provider,
328
+ model: llmConfig.model || getDefaultModel(llmConfig.provider),
329
+ apiKey,
330
+ hasApiKey: Boolean(apiKey),
331
+ baseUrl: llmConfig.baseUrl || LLM_DEFAULTS.baseUrl,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Get LLM settings from app settings, with the API key MASKED for the renderer.
337
+ * Uses unified defaults from shared/defaults.js
338
+ */
339
+ export function getLLMSettings(settingsManager) {
340
+ const raw = getLLMSettingsRaw(settingsManager);
341
+ const { apiKey } = raw;
342
+
307
343
  // SECURITY FIX (#25): Mask API key - only show last 4 chars to renderer
308
344
  const maskedApiKey =
309
345
  apiKey && apiKey.length > 8
@@ -313,20 +349,36 @@ export function getLLMSettings(settingsManager) {
313
349
  : '';
314
350
 
315
351
  return {
316
- enabled: llmConfig.enabled ?? LLM_DEFAULTS.enabled,
317
- provider: llmConfig.provider || LLM_DEFAULTS.provider,
318
- model: llmConfig.model || getDefaultModel(llmConfig.provider),
352
+ ...raw,
319
353
  apiKey: maskedApiKey,
320
- hasApiKey: Boolean(apiKey), // Let renderer know if key is set
321
- baseUrl: llmConfig.baseUrl || LLM_DEFAULTS.baseUrl,
322
354
  };
323
355
  }
324
356
 
325
357
  /**
326
- * Save LLM settings
358
+ * Resolve runtime settings coming from the renderer into settings with a REAL
359
+ * API key. If the renderer sent back the masked placeholder (key unchanged),
360
+ * substitute the stored real key. Used before any actual API call.
361
+ */
362
+ export function resolveRuntimeSettings(settingsManager, settings) {
363
+ if (isMaskedApiKey(settings.apiKey)) {
364
+ return { ...settings, apiKey: getStoredApiKey(settingsManager) };
365
+ }
366
+ return settings;
367
+ }
368
+
369
+ /**
370
+ * Save LLM settings. Never persist a masked placeholder key over the real one:
371
+ * if the renderer didn't change the key, keep the stored value.
327
372
  */
328
373
  export function saveLLMSettings(settingsManager, llmSettings) {
329
- settingsManager.set('creator.llm', llmSettings);
374
+ const next = { ...llmSettings };
375
+ delete next.hasApiKey; // renderer-only helper field, not real config
376
+
377
+ if (isMaskedApiKey(next.apiKey)) {
378
+ next.apiKey = getStoredApiKey(settingsManager);
379
+ }
380
+
381
+ settingsManager.set('creator.llm', next);
330
382
  }
331
383
 
332
384
  /**
@@ -151,9 +151,11 @@ export function registerCreatorHandlers(mainApp) {
151
151
  return { success: true };
152
152
  });
153
153
 
154
- // Test LLM connection
154
+ // Test LLM connection. The renderer may send back the masked key (unchanged);
155
+ // resolve it to the real stored key before calling the provider.
155
156
  ipcMain.handle(CREATOR_CHANNELS.TEST_LLM_CONNECTION, (_event, settings) => {
156
- return llmService.testLLMConnection(settings);
157
+ const resolved = llmService.resolveRuntimeSettings(mainApp.settings, settings);
158
+ return llmService.testLLMConnection(resolved);
157
159
  });
158
160
 
159
161
  log('✅ Creator handlers registered');
@@ -39,6 +39,8 @@ import { registerAutotuneHandlers } from './autotuneHandlers.js';
39
39
  log('✓ autotuneHandlers');
40
40
  import { registerCreatorHandlers } from './creatorHandlers.js';
41
41
  log('✓ creatorHandlers');
42
+ import { registerStreamingHandlers } from './streamingHandlers.js';
43
+ log('✓ streamingHandlers');
42
44
 
43
45
  /**
44
46
  * Register all IPC handlers
@@ -72,6 +74,9 @@ export function registerAllHandlers(mainApp) {
72
74
  // Creator handlers
73
75
  registerCreatorHandlers(mainApp);
74
76
 
77
+ // Streaming (browser viewer) handlers
78
+ registerStreamingHandlers(mainApp);
79
+
75
80
  log('✅ All IPC handlers registered');
76
81
  } catch (error) {
77
82
  console.error('❌ Failed to register IPC handlers:', error);
@@ -0,0 +1,70 @@
1
+ import { log } from '../logger.js';
2
+ import { ipcMain, shell } from 'electron';
3
+
4
+ /**
5
+ * Streaming IPC Handlers
6
+ *
7
+ * Bridges signaling between the embedded web server's Socket.IO (admin-authed
8
+ * `viewer-clients` room) and the renderer-side StreamingSender. Also exposes a
9
+ * helper for opening the viewer URL in the system browser.
10
+ */
11
+
12
+ /**
13
+ * Register all streaming-related IPC handlers.
14
+ * @param {Object} mainApp - Main application instance
15
+ */
16
+ export function registerStreamingHandlers(mainApp) {
17
+ // Compute the URL the admin should open to view the stream.
18
+ const getViewerUrl = () => {
19
+ const port = mainApp.webServer?.port;
20
+ if (!port) return null;
21
+ // Use loopback by default; admin can manually substitute the LAN IP when
22
+ // pointing a TV/phone browser at this URL.
23
+ return `http://localhost:${port}/viewer`;
24
+ };
25
+
26
+ ipcMain.handle('streaming:getViewerUrl', () => {
27
+ return { url: getViewerUrl() };
28
+ });
29
+
30
+ ipcMain.handle('streaming:openViewer', async () => {
31
+ const url = getViewerUrl();
32
+ if (!url) {
33
+ return { success: false, error: 'Web server not running' };
34
+ }
35
+ try {
36
+ await shell.openExternal(url);
37
+ return { success: true, url };
38
+ } catch (err) {
39
+ log('Failed to open viewer URL:', err);
40
+ return { success: false, error: err.message };
41
+ }
42
+ });
43
+
44
+ // Renderer → web server: forward an offer to a specific viewer socket
45
+ ipcMain.handle('streaming:sendViewerOffer', (_event, { viewerId, offer }) => {
46
+ mainApp.webServer?.sendToViewer?.(viewerId, 'viewer:offer', { offer });
47
+ });
48
+
49
+ // Renderer → web server: forward an ICE candidate to a specific viewer socket
50
+ ipcMain.handle('streaming:sendViewerICE', (_event, { viewerId, candidate }) => {
51
+ mainApp.webServer?.sendToViewer?.(viewerId, 'viewer:ice', { candidate });
52
+ });
53
+
54
+ // Stats for debugging
55
+ ipcMain.handle('streaming:getStats', () => {
56
+ const count = mainApp.webServer?.getViewerCount?.() ?? 0;
57
+ return { viewerCount: count };
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Forward signaling events from the web server (called from webServer.js
63
+ * when a viewer socket sends an event) to the main renderer where the
64
+ * StreamingSender lives.
65
+ */
66
+ export function forwardViewerEvent(mainApp, event, payload) {
67
+ if (mainApp.mainWindow && !mainApp.mainWindow.isDestroyed()) {
68
+ mainApp.mainWindow.webContents.send(`streaming:${event}`, payload);
69
+ }
70
+ }
@@ -96,6 +96,31 @@ const api = {
96
96
  sendFrame: (dataUrl) => ipcRenderer.invoke('canvas:sendFrame', dataUrl),
97
97
  },
98
98
 
99
+ streaming: {
100
+ // Open the browser viewer URL via the system browser
101
+ openViewer: () => ipcRenderer.invoke('streaming:openViewer'),
102
+ // Get the viewer URL (for showing in UI without opening)
103
+ getViewerUrl: () => ipcRenderer.invoke('streaming:getViewerUrl'),
104
+
105
+ // Outbound signaling to viewers (sent through the web server's Socket.IO)
106
+ sendViewerOffer: ({ viewerId, offer }) =>
107
+ ipcRenderer.invoke('streaming:sendViewerOffer', { viewerId, offer }),
108
+ sendViewerICE: ({ viewerId, candidate }) =>
109
+ ipcRenderer.invoke('streaming:sendViewerICE', { viewerId, candidate }),
110
+
111
+ // Inbound signaling from viewers (forwarded from the web server)
112
+ onViewerJoin: (callback) =>
113
+ ipcRenderer.on('streaming:viewerJoin', (_e, payload) => callback(payload)),
114
+ onViewerAnswer: (callback) =>
115
+ ipcRenderer.on('streaming:viewerAnswer', (_e, payload) => callback(payload)),
116
+ onViewerICE: (callback) =>
117
+ ipcRenderer.on('streaming:viewerICE', (_e, payload) => callback(payload)),
118
+ onViewerLeave: (callback) =>
119
+ ipcRenderer.on('streaming:viewerLeave', (_e, payload) => callback(payload)),
120
+
121
+ getStats: () => ipcRenderer.invoke('streaming:getStats'),
122
+ },
123
+
99
124
  library: {
100
125
  getSongsFolder: () => ipcRenderer.invoke('library:getSongsFolder'),
101
126
  setSongsFolder: () => ipcRenderer.invoke('library:setSongsFolder'),
@@ -188,10 +213,16 @@ const api = {
188
213
  sendWebRTCResponse: (command, result) => {
189
214
  // SECURITY FIX (#24): Whitelist allowed WebRTC commands to prevent IPC channel injection
190
215
  const ALLOWED_COMMANDS = [
216
+ // Receiver side (canvas window)
191
217
  'setupReceiver',
192
218
  'checkReceiverReady',
193
219
  'setOfferAndCreateAnswer',
194
220
  'getReceiverStatus',
221
+ // Sender side (main window)
222
+ 'setupSender',
223
+ 'createOffer',
224
+ 'setAnswer',
225
+ 'getSenderStatus',
195
226
  ];
196
227
  if (!ALLOWED_COMMANDS.includes(command)) {
197
228
  console.warn('Blocked invalid WebRTC command:', command);
@@ -7,6 +7,7 @@ import os from 'os';
7
7
  import crypto from 'crypto';
8
8
  import bcrypt from 'bcryptjs';
9
9
  import cookieSession from 'cookie-session';
10
+ import Keygrip from 'keygrip';
10
11
  import { Server } from 'socket.io';
11
12
  import http from 'http';
12
13
  import rateLimit from 'express-rate-limit';
@@ -24,6 +25,7 @@ import { getSetting } from '../shared/services/settingsService.js';
24
25
  import * as serverSettingsService from '../shared/services/serverSettingsService.js';
25
26
  import * as creatorService from '../shared/services/creatorService.js';
26
27
  import { validateSongPath, validateBase64Path } from './utils/pathValidator.js';
28
+ import { forwardViewerEvent } from './handlers/streamingHandlers.js';
27
29
 
28
30
  // ESM equivalent of __dirname
29
31
  const __filename = fileURLToPath(import.meta.url);
@@ -54,10 +56,30 @@ class WebServer {
54
56
  this.songPathToId = new Map();
55
57
  this.songIdToPath = new Map();
56
58
 
59
+ // Viewer sockets keyed by viewerId for WebRTC signaling
60
+ this.viewerSockets = new Map();
61
+
57
62
  this.setupMiddleware();
58
63
  this.setupRoutes();
59
64
  }
60
65
 
66
+ /**
67
+ * Send a Socket.IO event to a specific viewer by id.
68
+ * Called from streamingHandlers when the renderer-side sender produces
69
+ * an offer or ICE candidate for that viewer.
70
+ */
71
+ sendToViewer(viewerId, event, payload) {
72
+ const socket = this.viewerSockets.get(viewerId);
73
+ if (socket) {
74
+ socket.emit(event, payload);
75
+ }
76
+ }
77
+
78
+ /** Number of currently connected, authenticated viewer sockets. */
79
+ getViewerCount() {
80
+ return this.viewerSockets.size;
81
+ }
82
+
61
83
  /**
62
84
  * Generate an opaque ID for a song path (security: don't expose file paths)
63
85
  * Uses a deterministic hash so the same path always gets the same ID
@@ -313,6 +335,20 @@ class WebServer {
313
335
  }
314
336
  });
315
337
 
338
+ // Browser viewer page — admin-auth gated. Unlike requireAuth (which is
339
+ // for API endpoints and returns JSON 401), redirect unauthenticated
340
+ // browsers to the admin login UI so the user gets a sensible flow.
341
+ const requireAuthOrRedirect = (req, res, next) => {
342
+ if (req.session && req.session.isAdmin) {
343
+ next();
344
+ } else {
345
+ res.redirect('/admin');
346
+ }
347
+ };
348
+ this.app.get('/viewer', requireAuthOrRedirect, (req, res) => {
349
+ res.sendFile(path.join(__dirname, '../../static/viewer.html'));
350
+ });
351
+
316
352
  // Get available letters for alphabet navigation
317
353
  this.app.get('/api/letters', async (req, res) => {
318
354
  try {
@@ -2025,6 +2061,23 @@ class WebServer {
2025
2061
  socket.join('electron-apps');
2026
2062
  } else if (data.type === 'web-ui') {
2027
2063
  socket.join('web-clients');
2064
+ } else if (data.type === 'viewer') {
2065
+ // Viewer (browser tab opened from "Open Viewer" admin action) — auth-gated.
2066
+ const session = this.validateSocketSession(socket);
2067
+ if (!session || !session.isAdmin) {
2068
+ console.warn('⚠️ Unauthorized viewer connection attempt:', socket.id);
2069
+ socket.emit('auth-error', { message: 'Admin authentication required' });
2070
+ return;
2071
+ }
2072
+ const viewerId = socket.id;
2073
+ socket.viewerId = viewerId;
2074
+ socket.join('viewer-clients');
2075
+ this.viewerSockets.set(viewerId, socket);
2076
+ log(`🎥 Viewer connected: ${viewerId}`);
2077
+ socket.emit('viewer:ready');
2078
+ // Tell the renderer-side StreamingSender to build a peer connection
2079
+ // and send an offer back through us for this viewer.
2080
+ forwardViewerEvent(this.mainApp, 'viewerJoin', { viewerId });
2028
2081
  } else if (data.type === 'admin') {
2029
2082
  // SECURITY FIX (#22): Validate admin session before allowing admin room access
2030
2083
  const session = this.validateSocketSession(socket);
@@ -2062,9 +2115,33 @@ class WebServer {
2062
2115
  }
2063
2116
  });
2064
2117
 
2118
+ // Viewer signaling (answer + ICE candidates from the browser viewer)
2119
+ socket.on('viewer:answer', ({ answer }) => {
2120
+ if (socket.viewerId && this.viewerSockets.has(socket.viewerId)) {
2121
+ forwardViewerEvent(this.mainApp, 'viewerAnswer', {
2122
+ viewerId: socket.viewerId,
2123
+ answer,
2124
+ });
2125
+ }
2126
+ });
2127
+
2128
+ socket.on('viewer:ice', ({ candidate }) => {
2129
+ if (socket.viewerId && this.viewerSockets.has(socket.viewerId)) {
2130
+ forwardViewerEvent(this.mainApp, 'viewerICE', {
2131
+ viewerId: socket.viewerId,
2132
+ candidate,
2133
+ });
2134
+ }
2135
+ });
2136
+
2065
2137
  // Handle disconnection
2066
2138
  socket.on('disconnect', () => {
2067
2139
  log('Client disconnected:', socket.id);
2140
+ if (socket.viewerId && this.viewerSockets.has(socket.viewerId)) {
2141
+ this.viewerSockets.delete(socket.viewerId);
2142
+ forwardViewerEvent(this.mainApp, 'viewerLeave', { viewerId: socket.viewerId });
2143
+ log(`🎥 Viewer disconnected: ${socket.viewerId}`);
2144
+ }
2068
2145
  });
2069
2146
 
2070
2147
  // Song request events
@@ -2520,7 +2597,6 @@ class WebServer {
2520
2597
  }
2521
2598
 
2522
2599
  // Verify signature using Keygrip (same mechanism as cookie-session)
2523
- const Keygrip = require('keygrip');
2524
2600
  const keys = new Keygrip([this.getOrCreateSecretKey()]);
2525
2601
 
2526
2602
  if (!keys.verify('kai-admin-session=' + sessionCookie, sigCookie)) {