From a54f03f0cd1198449e7bd226ad95c7001b8214e0 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 20 May 2026 00:46:11 -0700 Subject: [PATCH] Fix Jellyfin Login (#76) --- bun.lock | 11 - changes/358-jellyfin-setup-feedback.md | 4 + changes/remove-jellyfin-server-presets.md | 4 + changes/windows-startup-fatal-errors.md | 4 + changes/windows-update-check-crash.md | 4 + docs-site/jellyfin-integration.md | 4 +- docs/RELEASING.md | 1 + package-lock.json | 38 ---- package.json | 1 - src/core/services/jellyfin.test.ts | 34 +++ src/core/services/jellyfin.ts | 51 ++++- src/main-entry.ts | 19 +- src/main.ts | 64 +++++- src/main/fatal-error.test.ts | 44 ++++ src/main/fatal-error.ts | 131 ++++++++++++ .../jellyfin-setup-window-main-deps.test.ts | 9 +- .../jellyfin-setup-window-main-deps.ts | 3 + .../runtime/jellyfin-setup-window.test.ts | 173 +++++++++++++-- src/main/runtime/jellyfin-setup-window.ts | 179 +++++++++++----- src/main/runtime/setup-window-factory.test.ts | 17 ++ src/main/runtime/setup-window-factory.ts | 2 + src/main/runtime/update/app-updater.test.ts | 4 +- src/main/runtime/update/app-updater.ts | 3 + .../runtime/update/curl-http-executor.test.ts | 10 +- src/main/runtime/update/curl-http-executor.ts | 11 +- src/main/runtime/update/fetch-adapter.test.ts | 31 ++- src/main/runtime/update/fetch-adapter.ts | 13 ++ .../update/fetch-http-executor.test.ts | 129 ++++++++++++ .../runtime/update/fetch-http-executor.ts | 197 ++++++++++++++++++ src/preload-jellyfin-setup.ts | 39 ++++ src/shared/ipc/contracts.ts | 1 + 31 files changed, 1087 insertions(+), 148 deletions(-) create mode 100644 changes/358-jellyfin-setup-feedback.md create mode 100644 changes/remove-jellyfin-server-presets.md create mode 100644 changes/windows-startup-fatal-errors.md create mode 100644 changes/windows-update-check-crash.md create mode 100644 src/main/fatal-error.test.ts create mode 100644 src/main/fatal-error.ts create mode 100644 src/main/runtime/update/fetch-http-executor.test.ts create mode 100644 src/main/runtime/update/fetch-http-executor.ts create mode 100644 src/preload-jellyfin-setup.ts diff --git a/bun.lock b/bun.lock index 971ad015..c58076c0 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,6 @@ "jsonc-parser": "^3.3.1", "koffi": "^2.15.6", "libsql": "^0.5.22", - "vscode-json-languageservice": "^5.7.2", "ws": "^8.19.0", }, "devDependencies": { @@ -189,8 +188,6 @@ "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], - "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], - "@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="], @@ -725,14 +722,6 @@ "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], - "vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], diff --git a/changes/358-jellyfin-setup-feedback.md b/changes/358-jellyfin-setup-feedback.md new file mode 100644 index 00000000..252d3964 --- /dev/null +++ b/changes/358-jellyfin-setup-feedback.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Fixed the Jellyfin setup popup login path on Windows by using an IPC bridge, showing immediate login progress, and timing out unreachable server login attempts with an inline error. diff --git a/changes/remove-jellyfin-server-presets.md b/changes/remove-jellyfin-server-presets.md new file mode 100644 index 00000000..329a137e --- /dev/null +++ b/changes/remove-jellyfin-server-presets.md @@ -0,0 +1,4 @@ +type: changed +area: jellyfin + +- Removed the Jellyfin setup server presets dropdown; setup now shows a single editable server URL field. diff --git a/changes/windows-startup-fatal-errors.md b/changes/windows-startup-fatal-errors.md new file mode 100644 index 00000000..578fbaa4 --- /dev/null +++ b/changes/windows-startup-fatal-errors.md @@ -0,0 +1,4 @@ +type: fixed +area: windows + +- Windows startup failures now show a native error dialog and write fatal details to the SubMiner app log instead of exiting silently. diff --git a/changes/windows-update-check-crash.md b/changes/windows-update-check-crash.md new file mode 100644 index 00000000..88806a5c --- /dev/null +++ b/changes/windows-update-check-crash.md @@ -0,0 +1,4 @@ +type: fixed +area: updates + +- Windows automatic updates now keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch without requiring `curl.exe`. diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 4ee649b0..7b4379bc 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -6,7 +6,7 @@ SubMiner includes an optional Jellyfin CLI integration for: - listing libraries and media items - launching item playback in the connected mpv instance - receiving Jellyfin remote cast-to-device playback events in-app -- opening an in-app setup window for server selection and authentication +- opening an in-app setup window for server URL and authentication - toggling Jellyfin cast discovery from the tray once configured ## Requirements @@ -50,7 +50,7 @@ subminer jellyfin -l \ --password 'your-password' ``` -`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. +`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. 3. List libraries: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e014023c..9386aa9b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -90,6 +90,7 @@ Notes: - Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. - macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer. - macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. +- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks. - Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed. - The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code. - Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior. diff --git a/package-lock.json b/package-lock.json index 55c1eb2c..da3a515c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "jsonc-parser": "^3.3.1", "koffi": "^2.15.6", "libsql": "^0.5.22", - "vscode-json-languageservice": "^5.7.2", "ws": "^8.19.0" }, "devDependencies": { @@ -744,12 +743,6 @@ "npm": ">=7.0.0" } }, - "node_modules/@vscode/l10n": { - "version": "0.0.18", - "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", - "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", - "license": "MIT" - }, "node_modules/@xhayper/discord-rpc": { "version": "1.3.3", "license": "ISC", @@ -3950,37 +3943,6 @@ "node": ">=0.6.0" } }, - "node_modules/vscode-json-languageservice": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz", - "integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==", - "license": "MIT", - "dependencies": { - "@vscode/l10n": "^0.0.18", - "jsonc-parser": "^3.3.1", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-languageserver-types": "^3.17.5", - "vscode-uri": "^3.1.0" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" - }, "node_modules/wcwidth": { "version": "1.0.1", "dev": true, diff --git a/package.json b/package.json index 73add775..596e9a61 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,6 @@ "jsonc-parser": "^3.3.1", "koffi": "^2.15.6", "libsql": "^0.5.22", - "vscode-json-languageservice": "^5.7.2", "ws": "^8.19.0" }, "devDependencies": { diff --git a/src/core/services/jellyfin.test.ts b/src/core/services/jellyfin.test.ts index 99d039d8..c8384f77 100644 --- a/src/core/services/jellyfin.test.ts +++ b/src/core/services/jellyfin.test.ts @@ -654,6 +654,40 @@ test('authenticateWithPassword surfaces invalid credentials and server status fa } }); +test('authenticateWithPassword surfaces unreachable server failures', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new TypeError('fetch failed'); + }) as typeof fetch; + + try { + await assert.rejects( + () => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'pw', clientInfo), + /Could not reach Jellyfin server \(fetch failed\)\./, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('authenticateWithPassword surfaces login timeouts', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + const error = new Error('aborted') as Error & { name: string }; + error.name = 'AbortError'; + throw error; + }) as typeof fetch; + + try { + await assert.rejects( + () => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'pw', clientInfo), + /Jellyfin login timed out\./, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('listLibraries surfaces token-expiry auth errors', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => diff --git a/src/core/services/jellyfin.ts b/src/core/services/jellyfin.ts index a9acaa8e..46de6809 100644 --- a/src/core/services/jellyfin.ts +++ b/src/core/services/jellyfin.ts @@ -1,6 +1,7 @@ import { JellyfinConfig } from '../../types'; const JELLYFIN_TICKS_PER_SECOND = 10_000_000; +const JELLYFIN_LOGIN_TIMEOUT_MS = 15_000; export interface JellyfinAuthSession { serverUrl: string; @@ -116,6 +117,21 @@ function asIntegerOrNull(value: unknown): number | null { return typeof value === 'number' && Number.isInteger(value) ? value : null; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isAbortError(error: unknown): boolean { + return isRecord(error) && error.name === 'AbortError'; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return String(error || 'unknown error'); +} + function resolveDeliveryUrl( session: JellyfinAuthSession, stream: JellyfinMediaStream, @@ -309,17 +325,30 @@ export async function authenticateWithPassword( if (!username.trim()) throw new Error('Missing Jellyfin username.'); if (!password) throw new Error('Missing Jellyfin password.'); - const response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: createAuthorizationHeader(client), - }, - body: JSON.stringify({ - Username: username, - Pw: password, - }), - }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), JELLYFIN_LOGIN_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: createAuthorizationHeader(client), + }, + body: JSON.stringify({ + Username: username, + Pw: password, + }), + signal: controller.signal, + }); + } catch (error) { + if (isAbortError(error)) { + throw new Error('Jellyfin login timed out. Check the server URL and network connection.'); + } + throw new Error(`Could not reach Jellyfin server (${getErrorMessage(error)}).`); + } finally { + clearTimeout(timeout); + } if (response.status === 401 || response.status === 403) { throw new Error('Invalid Jellyfin username or password.'); diff --git a/src/main-entry.ts b/src/main-entry.ts index b58f438c..16816868 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -28,6 +28,7 @@ import { import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { parseMpvLaunchMode } from './shared/mpv-launch-mode'; import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; +import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error'; const DEFAULT_TEXTHOOKER_PORT = 5174; @@ -173,6 +174,14 @@ function readConfiguredWindowsMpvLaunch(configDir: string): { process.argv = normalizeStartupArgv(process.argv, process.env); applySanitizedEnv(sanitizeStartupEnv(process.env)); const userDataPath = configureEarlyAppPaths(app); +const reportFatalError = createFatalErrorReporter({ + showErrorBox: (title, details) => dialog.showErrorBox(title, details), + consoleError: (message, error) => console.error(message, error), +}); +registerFatalErrorHandlers({ + reportFatalError, + exit: (code) => app.exit(code), +}); if (shouldDetachBackgroundLaunch(process.argv, process.env)) { const child = spawn(process.execPath, process.argv.slice(1), { @@ -226,5 +235,13 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { if (!gotSingleInstanceLock) { app.exit(0); } - require('./main.js'); + try { + require('./main.js'); + } catch (error) { + reportFatalError(error, { + title: 'SubMiner startup failed', + context: 'SubMiner failed while loading the main process.', + }); + app.exit(1); + } } diff --git a/src/main.ts b/src/main.ts index c2d16bf1..41ce9cd8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,6 +119,7 @@ import { resolveDefaultLogFilePath, type LogLevelSource, } from './logger'; +import { createFatalErrorReporter } from './main/fatal-error'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { bindWindowsOverlayAboveMpv, @@ -498,8 +499,9 @@ import { createElectronAppUpdater, isNativeUpdaterSupported, } from './main/runtime/update/app-updater'; -import { createElectronNetFetch } from './main/runtime/update/fetch-adapter'; +import { createElectronNetFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter'; import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor'; +import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor'; import { fetchLatestStableRelease, fetchReleaseAssetBuffer, @@ -605,6 +607,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60; const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; const TRAY_TOOLTIP = 'SubMiner'; +const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js'); let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState = createInitialAnilistMediaGuessRuntimeState(); @@ -862,6 +865,10 @@ const appLogger = { ); }, }; +const reportFatalError = createFatalErrorReporter({ + showErrorBox: (title, details) => dialog.showErrorBox(title, details), + consoleError: (message, error) => logger.error(message, error), +}); let forceQuitTimer: ReturnType | null = null; let statsServer: ReturnType | null = null; @@ -2775,6 +2782,7 @@ const { openJellyfinSetupWindowMainDeps: { createSetupWindow: createCreateJellyfinSetupWindowHandler({ createBrowserWindow: (options) => new BrowserWindow(options), + preloadPath: JELLYFIN_SETUP_PRELOAD_PATH, }), buildSetupFormHtml: (state) => buildJellyfinSetupFormHtml(state), parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), @@ -2822,6 +2830,24 @@ const { setSetupWindow: (window) => { appState.jellyfinSetupWindow = window as BrowserWindow; }, + registerSetupIpcHandler: (handler) => { + const channel = IPC_CHANNELS.request.jellyfinSetupSubmit; + ipcMain.removeHandler(channel); + ipcMain.handle(channel, async (event, payload) => { + const setupWindow = appState.jellyfinSetupWindow; + if (!setupWindow || event.sender !== setupWindow.webContents) { + return { + handled: false, + statusMessage: 'This Jellyfin setup window is no longer active.', + statusKind: 'error', + }; + } + return handler(payload); + }); + return () => { + ipcMain.removeHandler(channel); + }; + }, encodeURIComponent: (value) => encodeURIComponent(value), defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl || 'http://127.0.0.1:8096', hasStoredSession: () => Boolean(jellyfinTokenStore.loadSession()), @@ -3943,6 +3969,20 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ immersionTrackerStartupMainDeps, }); +async function runAppReadyRuntimeWithFatalReporting(): Promise { + try { + await appReadyRuntimeRunner(); + } catch (error) { + reportFatalError(error, { + title: 'SubMiner startup failed', + context: 'SubMiner failed during app-ready startup.', + }); + process.exitCode = 1; + requestAppQuit(); + return; + } +} + function ensureOverlayStartupPrereqs(): void { if (appState.subtitlePosition === null) { loadSubtitlePosition(); @@ -4656,8 +4696,22 @@ let updateService: ReturnType | null = null; const electronNetFetch = createElectronNetFetch({ fetch: (url, init) => net.fetch(url, init as RequestInit), }); +const globalFetchForUpdater = createGlobalFetch(); + +function createNativeUpdaterHttpExecutor() { + if (process.platform === 'darwin') { + return createCurlHttpExecutor(); + } + if (process.platform === 'win32') { + return createFetchHttpExecutor(); + } + return undefined; +} function getFetchForUpdater() { + if (process.platform === 'win32') { + return globalFetchForUpdater; + } return electronNetFetch; } @@ -4706,8 +4760,10 @@ function getUpdateService() { log: (message) => logger.info(message), getChannel: () => getResolvedConfig().updates.channel, configureHttpExecutor: - process.platform === 'darwin' ? () => createCurlHttpExecutor() : undefined, - disableDifferentialDownload: process.platform === 'darwin', + process.platform === 'darwin' || process.platform === 'win32' + ? createNativeUpdaterHttpExecutor + : undefined, + disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32', isNativeUpdaterSupported: () => isNativeUpdaterSupported({ platform: process.platform, @@ -5500,7 +5556,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers< handleCliCommand(nextArgs, source), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), logNoRunningInstance: () => appLogger.logNoRunningInstance(), - onReady: appReadyRuntimeRunner, + onReady: runAppReadyRuntimeWithFatalReporting, onWillQuitCleanup: () => onWillQuitCleanupHandler(), shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), diff --git a/src/main/fatal-error.test.ts b/src/main/fatal-error.test.ts new file mode 100644 index 00000000..bde84d8c --- /dev/null +++ b/src/main/fatal-error.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildFatalErrorDetails, + createFatalErrorReporter, + resetFatalErrorReporterForTests, +} from './fatal-error'; + +test('buildFatalErrorDetails includes context, error, and log path', () => { + const details = buildFatalErrorDetails({ + context: 'Startup failed.', + error: new Error('boom'), + logFilePath: 'C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\app.log', + }); + + assert.match(details, /Startup failed\./); + assert.match(details, /Error: boom/); + assert.match(details, /Log file: C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\app\.log/); +}); + +test('fatal error reporter writes one log entry and shows one dialog', () => { + resetFatalErrorReporterForTests(); + const logLines: string[] = []; + const dialogs: string[] = []; + + const reporter = createFatalErrorReporter({ + appendLogLine: (line) => logLines.push(line), + showErrorBox: (title, details) => dialogs.push(`${title}:${details}`), + resolveLogFilePath: () => 'C:\\SubMiner\\logs\\app.log', + now: () => new Date('2026-05-20T01:02:03.000Z'), + }); + + reporter('first failure', { + title: 'SubMiner startup failed', + context: 'SubMiner could not start.', + }); + reporter('second failure'); + + assert.equal(logLines.length, 1); + assert.match(logLines[0]!, /\[main:fatal\] SubMiner could not start\./); + assert.match(logLines[0]!, /first failure/); + assert.equal(dialogs.length, 1); + assert.match(dialogs[0]!, /^SubMiner startup failed:SubMiner could not start\./); +}); diff --git a/src/main/fatal-error.ts b/src/main/fatal-error.ts new file mode 100644 index 00000000..8a9aa94d --- /dev/null +++ b/src/main/fatal-error.ts @@ -0,0 +1,131 @@ +import { appendLogLine, resolveDefaultLogFilePath } from '../shared/log-files'; + +export type FatalErrorReportOptions = { + title: string; + context: string; +}; + +export type FatalErrorReporterDeps = { + showErrorBox: (title: string, details: string) => void; + consoleError?: (message: string, error?: unknown) => void; + appendLogLine?: (line: string) => void; + resolveLogFilePath?: () => string; + now?: () => Date; +}; + +export type FatalErrorReporter = ( + error: unknown, + options?: Partial, +) => void; + +const DEFAULT_TITLE = 'SubMiner crashed'; +const DEFAULT_CONTEXT = 'SubMiner encountered a fatal error'; + +let fatalErrorReported = false; + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +function formatTimestamp(date: Date): string { + return [ + date.getFullYear(), + '-', + pad(date.getMonth() + 1), + '-', + pad(date.getDate()), + ' ', + pad(date.getHours()), + ':', + pad(date.getMinutes()), + ':', + pad(date.getSeconds()), + ].join(''); +} + +function stringifyUnknownError(error: unknown): string { + if (error instanceof Error) { + return error.stack || error.message || error.name; + } + if (typeof error === 'string') { + return error; + } + try { + return JSON.stringify(error) ?? String(error); + } catch { + return String(error); + } +} + +export function buildFatalErrorDetails(options: { + context: string; + error: unknown; + logFilePath: string; +}): string { + return [ + options.context, + '', + stringifyUnknownError(options.error), + '', + `Log file: ${options.logFilePath}`, + ].join('\n'); +} + +export function createFatalErrorReporter(deps: FatalErrorReporterDeps): FatalErrorReporter { + return (error, options = {}) => { + if (fatalErrorReported) { + return; + } + fatalErrorReported = true; + + const title = options.title ?? DEFAULT_TITLE; + const context = options.context ?? DEFAULT_CONTEXT; + const logFilePath = deps.resolveLogFilePath?.() ?? resolveDefaultLogFilePath('app'); + const details = buildFatalErrorDetails({ context, error, logFilePath }); + const timestamp = formatTimestamp(deps.now?.() ?? new Date()); + const line = `[subminer] - ${timestamp} - ERROR - [main:fatal] ${details.replace(/\r?\n/g, ' | ')}`; + + try { + (deps.appendLogLine ?? ((entry: string) => appendLogLine(logFilePath, entry)))(line); + } catch { + // Fatal reporting must never throw while handling the original failure. + } + + try { + deps.consoleError?.(line, error); + } catch { + // ignore console sink failures + } + + try { + deps.showErrorBox(title, details); + } catch { + // If native dialogs are unavailable, the file log above is still the source of truth. + } + }; +} + +export function registerFatalErrorHandlers(deps: { + reportFatalError: FatalErrorReporter; + exit: (code: number) => void; +}): void { + process.on('uncaughtException', (error) => { + deps.reportFatalError(error, { + title: 'SubMiner crashed', + context: 'SubMiner main process threw an uncaught exception.', + }); + deps.exit(1); + }); + + process.on('unhandledRejection', (reason) => { + deps.reportFatalError(reason, { + title: 'SubMiner crashed', + context: 'SubMiner main process had an unhandled promise rejection.', + }); + deps.exit(1); + }); +} + +export function resetFatalErrorReporterForTests(): void { + fatalErrorReported = false; +} diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.test.ts b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts index 64c009f0..4d812a5d 100644 --- a/src/main/runtime/jellyfin-setup-window-main-deps.test.ts +++ b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts @@ -5,7 +5,6 @@ import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-se test('open jellyfin setup window main deps builder maps callbacks', async () => { const calls: string[] = []; const expectedState = { - servers: [], selectedServerUrl: 'a', username: 'b', hasStoredSession: false, @@ -46,6 +45,10 @@ test('open jellyfin setup window main deps builder maps callbacks', async () => showMpvOsd: (message) => calls.push(`osd:${message}`), clearSetupWindow: () => calls.push('clear'), setSetupWindow: () => calls.push('set-window'), + registerSetupIpcHandler: () => { + calls.push('register-ipc'); + return () => calls.push('unregister-ipc'); + }, encodeURIComponent: (value) => encodeURIComponent(value), defaultServerUrl: 'http://127.0.0.1:8096', hasStoredSession: () => true, @@ -97,6 +100,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () => deps.showMpvOsd('toast'); deps.clearSetupWindow(); deps.setSetupWindow({} as never); + const unregister = deps.registerSetupIpcHandler?.(async () => ({ handled: true })); + unregister?.(); assert.equal(deps.encodeURIComponent('a b'), 'a%20b'); assert.equal(deps.defaultServerUrl, 'http://127.0.0.1:8096'); assert.equal(deps.hasStoredSession(), true); @@ -110,5 +115,7 @@ test('open jellyfin setup window main deps builder maps callbacks', async () => 'osd:toast', 'clear', 'set-window', + 'register-ipc', + 'unregister-ipc', ]); }); diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.ts b/src/main/runtime/jellyfin-setup-window-main-deps.ts index 624490c8..416eddbd 100644 --- a/src/main/runtime/jellyfin-setup-window-main-deps.ts +++ b/src/main/runtime/jellyfin-setup-window-main-deps.ts @@ -25,6 +25,9 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler( showMpvOsd: (message: string) => deps.showMpvOsd(message), clearSetupWindow: () => deps.clearSetupWindow(), setSetupWindow: (window) => deps.setSetupWindow(window), + registerSetupIpcHandler: deps.registerSetupIpcHandler + ? (handler) => deps.registerSetupIpcHandler?.(handler) ?? (() => undefined) + : undefined, encodeURIComponent: (value: string) => deps.encodeURIComponent(value), defaultServerUrl: deps.defaultServerUrl, hasStoredSession: () => deps.hasStoredSession(), diff --git a/src/main/runtime/jellyfin-setup-window.test.ts b/src/main/runtime/jellyfin-setup-window.test.ts index a95adec5..8abac80b 100644 --- a/src/main/runtime/jellyfin-setup-window.test.ts +++ b/src/main/runtime/jellyfin-setup-window.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + buildJellyfinSetupSubmissionUrl, buildJellyfinSetupFormHtml, buildJellyfinSetupViewState, createHandleJellyfinSetupWindowClosedHandler, @@ -9,18 +10,12 @@ import { createHandleJellyfinSetupWindowOpenedHandler, createMaybeFocusExistingJellyfinSetupWindowHandler, createOpenJellyfinSetupWindowHandler, + normalizeJellyfinSetupIpcSubmission, parseJellyfinSetupSubmissionUrl, } from './jellyfin-setup-window'; test('buildJellyfinSetupFormHtml escapes default values', () => { const html = buildJellyfinSetupFormHtml({ - servers: [ - { - serverUrl: 'http://host/"x"', - label: 'Configured "Server"', - source: 'config', - }, - ], selectedServerUrl: 'http://host/"x"', username: 'user"name', hasStoredSession: true, @@ -31,11 +26,16 @@ test('buildJellyfinSetupFormHtml escapes default values', () => { assert.ok(html.includes('user"name')); assert.ok(html.includes('Ready "now"')); assert.ok(html.includes('Logout')); + assert.equal(html.includes('Server presets'), false); + assert.equal(html.includes('serverSelect'), false); + assert.ok(html.includes('window.subminerJellyfinSetup')); + assert.ok(html.includes('Logging in to Jellyfin')); assert.ok(html.includes('subminer://jellyfin-setup?')); - assert.equal(html.includes('params.set("password"'), false); + assert.equal(html.includes('params.set("password", passwordValue)'), false); + assert.ok(html.includes('window.__subminerJellyfinPassword = passwordValue')); }); -test('buildJellyfinSetupViewState composes config, recent, and default servers', () => { +test('buildJellyfinSetupViewState prefills configured server URL', () => { const state = buildJellyfinSetupViewState({ config: { serverUrl: ' http://configured:8096/ ', @@ -46,19 +46,25 @@ test('buildJellyfinSetupViewState composes config, recent, and default servers', hasStoredSession: false, }); - assert.deepEqual( - state.servers.map((server) => [server.serverUrl, server.source]), - [ - ['http://configured:8096', 'config'], - ['http://recent:8096', 'recent'], - ['http://127.0.0.1:8096', 'default'], - ], - ); assert.equal(state.selectedServerUrl, 'http://configured:8096'); assert.equal(state.username, 'alice'); assert.equal(state.statusKind, 'idle'); }); +test('buildJellyfinSetupViewState falls back to recent server URL', () => { + const state = buildJellyfinSetupViewState({ + config: { + serverUrl: '', + username: 'alice', + recentServers: ['http://recent:8096'], + }, + defaultServerUrl: 'http://127.0.0.1:8096', + hasStoredSession: false, + }); + + assert.equal(state.selectedServerUrl, 'http://recent:8096'); +}); + test('maybe focus jellyfin setup window no-ops without window', () => { const handler = createMaybeFocusExistingJellyfinSetupWindowHandler({ getSetupWindow: () => null, @@ -92,6 +98,38 @@ test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => { assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null); }); +test('jellyfin setup ipc submissions normalize to password-free setup urls', () => { + const submission = normalizeJellyfinSetupIpcSubmission({ + action: 'login', + server: 'http://localhost:8096', + username: 'alice', + password: 'secret', + }); + + assert.deepEqual(submission, { + action: 'login', + server: 'http://localhost:8096', + username: 'alice', + password: 'secret', + }); + if (!submission) { + throw new Error('missing normalized submission'); + } + const setupUrl = buildJellyfinSetupSubmissionUrl(submission); + assert.equal( + setupUrl, + 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost%3A8096&username=alice', + ); + assert.equal(setupUrl.includes('secret'), false); + assert.deepEqual(normalizeJellyfinSetupIpcSubmission({ action: 'done' }), { + action: 'done', + server: '', + username: '', + password: '', + }); + assert.equal(normalizeJellyfinSetupIpcSubmission('bad'), null); +}); + test('createHandleJellyfinSetupSubmissionHandler applies successful login', async () => { const calls: string[] = []; let patchPayload: unknown = null; @@ -512,3 +550,104 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li onClosed(); assert.ok(calls.includes('clear-window')); }); + +test('createOpenJellyfinSetupWindowHandler handles ipc bridge submissions', async () => { + const bridge: { handler?: (payload: unknown) => Promise<{ handled: boolean }> } = {}; + let closedHandler: (() => void) | null = null; + const calls: string[] = []; + const fakeWindow = { + focus: () => {}, + webContents: { + on: () => {}, + executeJavaScript: async () => { + throw new Error('bridge path should not read from page'); + }, + }, + loadURL: () => { + calls.push('load'); + }, + on: (event: 'closed', handler: () => void) => { + if (event === 'closed') { + closedHandler = handler; + } + }, + isDestroyed: () => false, + close: () => calls.push('close'), + }; + + const handler = createOpenJellyfinSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => fakeWindow, + getResolvedJellyfinConfig: () => ({ + serverUrl: 'http://localhost:8096', + username: 'alice', + recentServers: [], + }), + buildSetupFormHtml: () => '', + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: async (_server, _username, password) => { + calls.push(`password:${password}`); + return { + serverUrl: 'http://localhost:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }; + }, + getJellyfinClientInfo: () => ({ + clientName: 'SubMiner', + clientVersion: '1.0', + deviceId: 'did', + }), + saveStoredSession: () => calls.push('save'), + clearStoredSession: () => calls.push('clear'), + patchJellyfinConfig: () => calls.push('patch'), + logInfo: () => calls.push('info'), + logError: () => calls.push('error'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + clearSetupWindow: () => calls.push('clear-window'), + setSetupWindow: () => calls.push('set-window'), + registerSetupIpcHandler: (nextHandler) => { + bridge.handler = nextHandler; + calls.push('register-ipc'); + return () => calls.push('unregister-ipc'); + }, + encodeURIComponent: (value) => encodeURIComponent(value), + defaultServerUrl: 'http://127.0.0.1:8096', + hasStoredSession: () => false, + }); + + handler(); + const bridgeHandler = bridge.handler; + if (!bridgeHandler) { + throw new Error('missing bridge handler'); + } + assert.deepEqual(await bridgeHandler('bad'), { + handled: false, + statusMessage: 'Invalid Jellyfin setup request.', + statusKind: 'error', + }); + assert.equal(calls.includes('password:'), false); + + calls.length = 0; + assert.deepEqual( + await bridgeHandler({ + action: 'login', + server: 'http://localhost:8096', + username: 'alice', + password: 'secret', + }), + { handled: true }, + ); + assert.ok(calls.includes('password:secret')); + assert.ok(calls.includes('save')); + assert.ok(calls.includes('patch')); + + const onClosed = closedHandler as (() => void) | null; + if (!onClosed) { + throw new Error('missing closed handler'); + } + onClosed(); + assert.ok(calls.includes('unregister-ipc')); + assert.ok(calls.includes('clear-window')); +}); diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts index 4441491e..eae22bf1 100644 --- a/src/main/runtime/jellyfin-setup-window.ts +++ b/src/main/runtime/jellyfin-setup-window.ts @@ -32,15 +32,14 @@ type JellyfinSetupWindowLike = FocusableWindowLike & { export type JellyfinSetupAction = 'login' | 'logout' | 'done'; -export type JellyfinSetupServerOption = { - serverUrl: string; - label: string; - source: 'config' | 'recent' | 'default'; - username?: string; +export type JellyfinSetupSubmission = { + action: JellyfinSetupAction; + server: string; + username: string; + password: string; }; export type JellyfinSetupViewState = { - servers: JellyfinSetupServerOption[]; selectedServerUrl: string; username: string; hasStoredSession: boolean; @@ -55,6 +54,16 @@ type JellyfinSetupViewOverrides = { statusKind?: JellyfinSetupViewState['statusKind']; }; +export type JellyfinSetupIpcResult = { + handled: boolean; + statusMessage?: string; + statusKind?: JellyfinSetupViewState['statusKind']; +}; + +type RegisterJellyfinSetupIpcHandler = ( + handler: (submission: unknown) => Promise, +) => () => void; + function escapeHtmlAttr(value: string): string { return value.replace(/"/g, '"'); } @@ -67,6 +76,18 @@ function escapeHtml(value: string): string { .replace(/"/g, '"'); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeSetupAction(value: unknown): JellyfinSetupAction { + return value === 'logout' || value === 'done' ? value : 'login'; +} + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: { getSetupWindow: () => FocusableWindowLike | null; }) { @@ -96,27 +117,6 @@ export function buildJellyfinSetupViewState(input: { const configServer = normalizeJellyfinRecentServers([input.config.serverUrl || ''])[0] || ''; const recentServers = normalizeJellyfinRecentServers(input.config.recentServers || []); const defaultServer = normalizeJellyfinRecentServers([input.defaultServerUrl])[0] || ''; - const seen = new Set(); - const servers: JellyfinSetupServerOption[] = []; - - const addServer = (serverUrl: string, source: JellyfinSetupServerOption['source']) => { - if (!serverUrl || seen.has(serverUrl)) return; - seen.add(serverUrl); - servers.push({ - serverUrl, - label: - source === 'config' - ? `${serverUrl} (configured)` - : source === 'default' - ? `${serverUrl} (default)` - : serverUrl, - source, - }); - }; - - addServer(configServer, 'config'); - for (const recent of recentServers) addServer(recent, 'recent'); - addServer(defaultServer, 'default'); const selectedServerUrl = normalizeJellyfinRecentServers([input.selectedServerUrl || ''])[0] || @@ -125,7 +125,6 @@ export function buildJellyfinSetupViewState(input: { defaultServer; return { - servers, selectedServerUrl, username: input.username ?? input.config.username ?? '', hasStoredSession: input.hasStoredSession, @@ -135,14 +134,6 @@ export function buildJellyfinSetupViewState(input: { } export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): string { - const options = state.servers - .map( - (server) => - ``, - ) - .join(''); const statusClass = `status ${state.statusKind}`; return ` @@ -156,8 +147,9 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; } p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; } label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } - input, select { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; } + input { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; } button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; } + button:disabled { cursor: wait; opacity: .68; } button.secondary { background: transparent; color: var(--text); border-color: var(--line); } button.danger { background: transparent; color: var(--danger); border-color: #6b332f; } .actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; } @@ -171,17 +163,15 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin

Jellyfin Setup

-

Choose a server, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.

+

Enter your Jellyfin server URL, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.

- - -
${escapeHtml(state.statusMessage)}
+
${escapeHtml(state.statusMessage)}
${ @@ -196,19 +186,54 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin