loukai-app 0.5.0 → 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/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",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"@vitest/coverage-v8": "^3.2.4",
|
|
48
48
|
"@vitest/ui": "^3.2.4",
|
|
49
49
|
"autoprefixer": "^10.4.21",
|
|
50
|
+
"electron": "^40.6.0",
|
|
50
51
|
"electron-builder": "^26.0.12",
|
|
51
52
|
"eslint": "^9.37.0",
|
|
52
53
|
"eslint-config-prettier": "^10.1.8",
|
|
@@ -75,7 +76,6 @@
|
|
|
75
76
|
"cdgraphics": "^7.0.0",
|
|
76
77
|
"cookie-session": "^2.1.1",
|
|
77
78
|
"cors": "^2.8.5",
|
|
78
|
-
"electron": "^40.6.0",
|
|
79
79
|
"express": "^5.1.0",
|
|
80
80
|
"express-rate-limit": "^8.1.0",
|
|
81
81
|
"fuse.js": "^7.1.0",
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Ensure
|
|
3
|
+
* Ensure Electron is installed AND correctly extracted.
|
|
4
4
|
*
|
|
5
|
-
* Electron
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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:
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* PowerShell `Expand-Archive` on Windows), which is unaffected by the bug, and
|
|
16
|
-
* writes `path.txt` — making `npx loukai-app` reliable on any Node version.
|
|
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.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
30
|
import { createRequire } from 'module';
|
|
@@ -30,8 +38,11 @@ import {
|
|
|
30
38
|
mkdirSync,
|
|
31
39
|
} from 'fs';
|
|
32
40
|
import { join, dirname } from 'path';
|
|
41
|
+
import { fileURLToPath } from 'url';
|
|
33
42
|
|
|
34
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)
|
|
35
46
|
|
|
36
47
|
function log(msg) {
|
|
37
48
|
console.log(`[ensure-electron] ${msg}`);
|
|
@@ -53,6 +64,27 @@ function platformBinaryPath(platform) {
|
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
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
|
+
|
|
56
88
|
function isInstalled(electronDir, version, platformPath) {
|
|
57
89
|
try {
|
|
58
90
|
const distVersion = readFileSync(join(electronDir, 'dist', 'version'), 'utf-8').replace(/^v/, '');
|
|
@@ -68,7 +100,6 @@ function extractZip(zipPath, destDir) {
|
|
|
68
100
|
rmSync(destDir, { recursive: true, force: true });
|
|
69
101
|
mkdirSync(destDir, { recursive: true });
|
|
70
102
|
if (process.platform === 'win32') {
|
|
71
|
-
// PowerShell is present on all supported Windows versions.
|
|
72
103
|
execFileSync(
|
|
73
104
|
'powershell',
|
|
74
105
|
['-NoProfile', '-NonInteractive', '-Command', `Expand-Archive -Path "${zipPath}" -DestinationPath "${destDir}" -Force`],
|
|
@@ -80,41 +111,63 @@ function extractZip(zipPath, destDir) {
|
|
|
80
111
|
}
|
|
81
112
|
}
|
|
82
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
|
+
|
|
83
125
|
async function main() {
|
|
84
126
|
if (process.env.ELECTRON_SKIP_BINARY_DOWNLOAD) {
|
|
85
127
|
return; // user opted out of the binary entirely
|
|
86
128
|
}
|
|
87
129
|
|
|
88
|
-
let electronPkg;
|
|
89
|
-
try {
|
|
90
|
-
electronPkg = require.resolve('electron/package.json');
|
|
91
|
-
} catch {
|
|
92
|
-
return; // electron not installed (e.g. devDeps pruned) — nothing to repair
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const electronDir = dirname(electronPkg);
|
|
96
|
-
const { version } = require(electronPkg);
|
|
97
130
|
const platform = process.env.npm_config_platform || process.platform;
|
|
98
131
|
const arch = process.env.npm_config_arch || process.arch;
|
|
99
132
|
const platformPath = platformBinaryPath(platform);
|
|
100
|
-
|
|
101
133
|
if (!platformPath) {
|
|
102
134
|
log(`Unsupported platform "${platform}"; leaving Electron untouched.`);
|
|
103
135
|
return;
|
|
104
136
|
}
|
|
105
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 ----
|
|
106
162
|
if (isInstalled(electronDir, version, platformPath)) {
|
|
107
|
-
return; //
|
|
163
|
+
return; // healthy — silent no-op (the common dev-repo case)
|
|
108
164
|
}
|
|
109
165
|
|
|
110
166
|
log(`Electron ${version} is not fully extracted; repairing (Node ${process.version} extract-zip workaround)…`);
|
|
111
167
|
|
|
112
|
-
// Resolve the downloaded artifact zip. @electron/get returns the cached zip
|
|
113
|
-
// path, or downloads it if missing (the download path is unaffected by the
|
|
114
|
-
// extraction bug). Resolve it from Electron's own dependency tree.
|
|
115
168
|
let zipPath;
|
|
116
169
|
try {
|
|
117
|
-
const getRequire = createRequire(
|
|
170
|
+
const getRequire = createRequire(join(electronDir, 'package.json'));
|
|
118
171
|
const { downloadArtifact } = getRequire('@electron/get');
|
|
119
172
|
let checksums;
|
|
120
173
|
try {
|
|
@@ -122,13 +175,7 @@ async function main() {
|
|
|
122
175
|
} catch {
|
|
123
176
|
checksums = undefined;
|
|
124
177
|
}
|
|
125
|
-
zipPath = await downloadArtifact({
|
|
126
|
-
version,
|
|
127
|
-
artifactName: 'electron',
|
|
128
|
-
platform,
|
|
129
|
-
arch,
|
|
130
|
-
checksums,
|
|
131
|
-
});
|
|
178
|
+
zipPath = await downloadArtifact({ version, artifactName: 'electron', platform, arch, checksums });
|
|
132
179
|
} catch (err) {
|
|
133
180
|
log(`Could not obtain the Electron zip: ${err.message}`);
|
|
134
181
|
log('Run `npm rebuild electron` (Node 22 or earlier), or reinstall, to fix.');
|
|
@@ -138,8 +185,6 @@ async function main() {
|
|
|
138
185
|
const distDir = join(electronDir, 'dist');
|
|
139
186
|
try {
|
|
140
187
|
extractZip(zipPath, distDir);
|
|
141
|
-
|
|
142
|
-
// Mirror Electron's install.js: hoist the type defs and write path.txt.
|
|
143
188
|
const srcTypeDef = join(distDir, 'electron.d.ts');
|
|
144
189
|
if (existsSync(srcTypeDef)) {
|
|
145
190
|
renameSync(srcTypeDef, join(electronDir, 'electron.d.ts'));
|
|
@@ -152,13 +197,12 @@ async function main() {
|
|
|
152
197
|
}
|
|
153
198
|
|
|
154
199
|
if (isInstalled(electronDir, version, platformPath)) {
|
|
155
|
-
log('Electron
|
|
200
|
+
log('Electron ready.');
|
|
156
201
|
} else {
|
|
157
202
|
log('Repair did not produce a valid install; try `npm rebuild electron`.');
|
|
158
203
|
}
|
|
159
204
|
}
|
|
160
205
|
|
|
161
206
|
main().catch((err) => {
|
|
162
|
-
// Never fail the install over this.
|
|
163
207
|
log(`Unexpected error (ignored): ${err.message}`);
|
|
164
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');
|