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.
- package/README.md +9 -3
- package/package.json +5 -3
- package/scripts/ensure-electron.js +208 -0
- package/src/main/creator/conversionService.js +1 -1
- package/src/main/creator/llmService.js +62 -10
- package/src/main/handlers/creatorHandlers.js +4 -2
- package/src/main/handlers/index.js +5 -0
- package/src/main/handlers/streamingHandlers.js +70 -0
- package/src/main/preload.js +31 -0
- package/src/main/webServer.js +77 -1
- package/src/renderer/components/AppRoot.jsx +4 -0
- package/src/renderer/dist/assets/{kaiPlayer-DSaY7TxC.js → kaiPlayer-BsM-WzYQ.js} +2 -2
- package/src/renderer/dist/assets/kaiPlayer-BsM-WzYQ.js.map +1 -0
- package/src/renderer/dist/assets/streamingSender-HwIev870.js +2 -0
- package/src/renderer/dist/assets/streamingSender-HwIev870.js.map +1 -0
- package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js +2 -0
- package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js.map +1 -0
- package/src/renderer/dist/renderer.js +15 -15
- package/src/renderer/dist/renderer.js.map +1 -1
- package/src/renderer/js/kaiPlayer.js +15 -0
- package/src/renderer/js/streamingSender.js +292 -0
- package/src/renderer/js/webrtcManager.js +3 -0
- package/src/shared/components/PlayerControls.jsx +12 -0
- package/src/shared/hooks/useStreamingSender.js +30 -0
- package/src/web/App.jsx +1 -0
- package/src/web/dist/assets/index-DUPLO3h6.js +11 -0
- package/src/web/dist/assets/{index-CGbmW1VG.js.map → index-DUPLO3h6.js.map} +1 -1
- package/src/web/dist/index.html +1 -1
- package/static/viewer.html +421 -0
- package/src/renderer/dist/assets/kaiPlayer-DSaY7TxC.js.map +0 -1
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +0 -2
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +0 -1
- 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
|
-
- **
|
|
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
|
-
- **
|
|
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**:
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
301
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/main/preload.js
CHANGED
|
@@ -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);
|
package/src/main/webServer.js
CHANGED
|
@@ -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)) {
|