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.5.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 the Electron binary is correctly extracted.
3
+ * Ensure Electron is installed AND correctly extracted.
4
4
  *
5
- * Electron's own postinstall extracts its prebuilt binary with `extract-zip`
6
- * (which bundles the unmaintained `yauzl@2.x`). On Node 24 that extractor
7
- * silently stalls after the first zip entry, so the postinstall exits 0 with a
8
- * half-written `dist/` (only `LICENSES.chromium.html`, no `path.txt`). Later,
9
- * `require('electron')` throws "Electron failed to install correctly" and our
10
- * `bin/loukai.js` reports "Could not find Electron."
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
- * This runs as loukai-app's own postinstall (after Electron's). If Electron is
13
- * already installed correctly it is a silent no-op. Otherwise it re-extracts
14
- * the downloaded zip using the system's archive tool (`unzip` on macOS/Linux,
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
- * Best-effort: never fails the install. If repair is impossible it logs
19
- * actionable guidance and exits 0.
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; // already good — silent no-op (the common case)
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(electronPkg);
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 repaired successfully.');
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.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');