mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Fix Jellyfin Login (#76)
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: jellyfin
|
||||
|
||||
- Removed the Jellyfin setup server presets dropdown; setup now shows a single editable server URL field.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
-38
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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.');
|
||||
|
||||
+18
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+60
-4
@@ -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<typeof setTimeout> | null = null;
|
||||
let statsServer: ReturnType<typeof startStatsServer> | 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<void> {
|
||||
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<typeof createUpdateService> | 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(),
|
||||
|
||||
@@ -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\./);
|
||||
});
|
||||
@@ -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<FatalErrorReportOptions>,
|
||||
) => 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;
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: () => '<html></html>',
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -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<JellyfinSetupIpcResult>,
|
||||
) => () => 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<string, unknown> {
|
||||
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<string>();
|
||||
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) =>
|
||||
`<option value="${escapeHtmlAttr(server.serverUrl)}"${
|
||||
server.serverUrl === state.selectedServerUrl ? ' selected' : ''
|
||||
}>${escapeHtml(server.label)}</option>`,
|
||||
)
|
||||
.join('');
|
||||
const statusClass = `status ${state.statusKind}`;
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -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
|
||||
<body>
|
||||
<main>
|
||||
<h1>Jellyfin Setup</h1>
|
||||
<p>Choose a server, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.</p>
|
||||
<p>Enter your Jellyfin server URL, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.</p>
|
||||
<form id="form">
|
||||
<label for="serverSelect">Known servers</label>
|
||||
<select id="serverSelect">${options}</select>
|
||||
<label for="server">Server URL</label>
|
||||
<input id="server" name="server" value="${escapeHtmlAttr(state.selectedServerUrl)}" required />
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" value="${escapeHtmlAttr(state.username)}" required />
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required />
|
||||
<div id="status" class="${statusClass}">${escapeHtml(state.statusMessage)}</div>
|
||||
<div id="status" class="${statusClass}" aria-live="polite">${escapeHtml(state.statusMessage)}</div>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit">Login</button>
|
||||
${
|
||||
@@ -196,19 +186,54 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
|
||||
</main>
|
||||
<script>
|
||||
const form = document.getElementById("form");
|
||||
const select = document.getElementById("serverSelect");
|
||||
const server = document.getElementById("server");
|
||||
select?.addEventListener("change", () => {
|
||||
server.value = select.value || server.value;
|
||||
});
|
||||
function submitAction(action) {
|
||||
const username = document.getElementById("username");
|
||||
const password = document.getElementById("password");
|
||||
const status = document.getElementById("status");
|
||||
const buttons = Array.from(document.querySelectorAll("button"));
|
||||
function setBusy(message) {
|
||||
if (status) {
|
||||
status.textContent = message;
|
||||
status.className = "status loading";
|
||||
}
|
||||
for (const button of buttons) button.disabled = true;
|
||||
}
|
||||
function setStatus(message, kind) {
|
||||
if (status) {
|
||||
status.textContent = message;
|
||||
status.className = "status " + kind;
|
||||
}
|
||||
for (const button of buttons) button.disabled = false;
|
||||
}
|
||||
async function submitAction(action) {
|
||||
const serverValue = String(server?.value || "");
|
||||
const usernameValue = String(username?.value || "");
|
||||
const passwordValue = String(password?.value || "");
|
||||
setBusy(action === "login" ? "Logging in to Jellyfin..." : action === "logout" ? "Logging out..." : "Closing...");
|
||||
const bridge = window.subminerJellyfinSetup;
|
||||
if (bridge?.submit) {
|
||||
try {
|
||||
const result = await bridge.submit({
|
||||
action,
|
||||
server: serverValue,
|
||||
username: usernameValue,
|
||||
password: passwordValue,
|
||||
});
|
||||
if (result?.handled === false) {
|
||||
setStatus(result.statusMessage || "Jellyfin setup action was not accepted.", result.statusKind || "error");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error || "Unknown error");
|
||||
setStatus("Jellyfin setup action failed: " + message, "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set("action", action);
|
||||
if (action === "login") {
|
||||
const data = new FormData(form);
|
||||
params.set("server", String(data.get("server") || ""));
|
||||
params.set("username", String(data.get("username") || ""));
|
||||
window.__subminerJellyfinPassword = String(data.get("password") || "");
|
||||
params.set("server", serverValue);
|
||||
params.set("username", usernameValue);
|
||||
window.__subminerJellyfinPassword = passwordValue;
|
||||
}
|
||||
window.location.href = "subminer://jellyfin-setup?" + params.toString();
|
||||
}
|
||||
@@ -244,6 +269,30 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeJellyfinSetupIpcSubmission(
|
||||
value: unknown,
|
||||
): JellyfinSetupSubmission | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
action: normalizeSetupAction(value.action),
|
||||
server: normalizeString(value.server),
|
||||
username: normalizeString(value.username),
|
||||
password: normalizeString(value.password),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJellyfinSetupSubmissionUrl(submission: JellyfinSetupSubmission): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('action', submission.action);
|
||||
if (submission.action === 'login') {
|
||||
params.set('server', submission.server);
|
||||
params.set('username', submission.username);
|
||||
}
|
||||
return `subminer://jellyfin-setup?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||
parseSubmissionUrl: (
|
||||
rawUrl: string,
|
||||
@@ -432,6 +481,7 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
showMpvOsd: (message: string) => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
registerSetupIpcHandler?: RegisterJellyfinSetupIpcHandler;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
defaultServerUrl: string;
|
||||
hasStoredSession: () => boolean;
|
||||
@@ -480,14 +530,34 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
}
|
||||
},
|
||||
});
|
||||
const unregisterSetupIpcHandler = deps.registerSetupIpcHandler?.(async (payload) => {
|
||||
const submission = normalizeJellyfinSetupIpcSubmission(payload);
|
||||
if (!submission) {
|
||||
return {
|
||||
handled: false,
|
||||
statusMessage: 'Invalid Jellyfin setup request.',
|
||||
statusKind: 'error',
|
||||
};
|
||||
}
|
||||
const handled = await handleSubmission(
|
||||
buildJellyfinSetupSubmissionUrl(submission),
|
||||
submission.password,
|
||||
);
|
||||
return { handled };
|
||||
});
|
||||
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
|
||||
setupSchemePrefix: 'subminer://jellyfin-setup',
|
||||
handleSubmission: async (rawUrl) => {
|
||||
const submission = deps.parseSubmissionUrl(rawUrl);
|
||||
const password =
|
||||
submission?.action === 'login' && !submission.password
|
||||
? await readJellyfinSetupPasswordFromWindow(setupWindow)
|
||||
: undefined;
|
||||
let password: string | undefined;
|
||||
if (submission?.action === 'login' && !submission.password) {
|
||||
try {
|
||||
password = await readJellyfinSetupPasswordFromWindow(setupWindow);
|
||||
} catch (error) {
|
||||
deps.logError('Failed reading Jellyfin setup password', error);
|
||||
password = '';
|
||||
}
|
||||
}
|
||||
return handleSubmission(rawUrl, password);
|
||||
},
|
||||
logError: (message, error) => deps.logError(message, error),
|
||||
@@ -512,6 +582,7 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
});
|
||||
loadSetupForm();
|
||||
setupWindow.on('closed', () => {
|
||||
unregisterSetupIpcHandler?.();
|
||||
handleWindowClosed();
|
||||
});
|
||||
handleWindowOpened();
|
||||
|
||||
@@ -56,6 +56,23 @@ test('createCreateJellyfinSetupWindowHandler builds jellyfin setup window', () =
|
||||
});
|
||||
});
|
||||
|
||||
test('createCreateJellyfinSetupWindowHandler wires optional preload bridge', () => {
|
||||
const captured: { options?: Electron.BrowserWindowConstructorOptions } = {};
|
||||
const createSetupWindow = createCreateJellyfinSetupWindowHandler({
|
||||
createBrowserWindow: (nextOptions) => {
|
||||
captured.options = nextOptions;
|
||||
return { id: 'jellyfin' } as never;
|
||||
},
|
||||
preloadPath: 'C:\\SubMiner\\dist\\preload-jellyfin-setup.js',
|
||||
});
|
||||
|
||||
assert.deepEqual(createSetupWindow(), { id: 'jellyfin' });
|
||||
const options = captured.options;
|
||||
assert.ok(options);
|
||||
assert.equal(options.webPreferences?.preload, 'C:\\SubMiner\\dist\\preload-jellyfin-setup.js');
|
||||
assert.equal(options.webPreferences?.sandbox, true);
|
||||
});
|
||||
|
||||
test('createCreateAnilistSetupWindowHandler builds anilist setup window', () => {
|
||||
let options: Electron.BrowserWindowConstructorOptions | null = null;
|
||||
const createSetupWindow = createCreateAnilistSetupWindowHandler({
|
||||
|
||||
@@ -49,11 +49,13 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
|
||||
|
||||
export function createCreateJellyfinSetupWindowHandler<TWindow>(deps: {
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
preloadPath?: string;
|
||||
}) {
|
||||
return createSetupWindowHandler(deps, {
|
||||
width: 520,
|
||||
height: 560,
|
||||
title: 'Jellyfin Setup',
|
||||
...(deps.preloadPath ? { preloadPath: deps.preloadPath, sandbox: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -336,12 +336,12 @@ test('known Linux package-managed AppImage detection follows the canonical AUR p
|
||||
);
|
||||
});
|
||||
|
||||
test('native updater is unsupported on Windows by default', async () => {
|
||||
test('windows native updater is supported for packaged builds', async () => {
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.equal(supported, true);
|
||||
});
|
||||
|
||||
@@ -114,6 +114,9 @@ export async function isNativeUpdaterSupported(options: {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
if (options.platform !== 'darwin') {
|
||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||
return false;
|
||||
|
||||
@@ -44,9 +44,9 @@ test('curl HTTP executor requests updater metadata without Electron networking',
|
||||
});
|
||||
|
||||
test('curl HTTP executor downloads updater assets to the requested destination', async () => {
|
||||
const calls: Array<{ args: readonly string[] }> = [];
|
||||
const execFile: CurlExecFile = (_file, args, _options, callback) => {
|
||||
calls.push({ args });
|
||||
const calls: Array<{ args: readonly string[]; timeout?: number }> = [];
|
||||
const execFile: CurlExecFile = (_file, args, options, callback) => {
|
||||
calls.push({ args, timeout: options.timeout });
|
||||
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
|
||||
return { kill: () => true };
|
||||
};
|
||||
@@ -54,6 +54,7 @@ test('curl HTTP executor downloads updater assets to the requested destination',
|
||||
execFile,
|
||||
curlPath: '/usr/bin/curl',
|
||||
mkdir: async () => undefined,
|
||||
downloadTimeoutMs: 120_000,
|
||||
});
|
||||
|
||||
await executor.download(
|
||||
@@ -75,12 +76,15 @@ test('curl HTTP executor downloads updater assets to the requested destination',
|
||||
'--show-error',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'120',
|
||||
'--header',
|
||||
'User-Agent: SubMiner updater',
|
||||
'--output',
|
||||
'/tmp/subminer/update.zip',
|
||||
'https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip',
|
||||
]);
|
||||
assert.equal(calls[0]?.timeout, 120_000);
|
||||
});
|
||||
|
||||
test('curl HTTP executor verifies downloaded updater asset hashes', async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ type CurlDownloadOptions = {
|
||||
sha2?: string | null;
|
||||
sha512?: string | null;
|
||||
cancellationToken: CancellationTokenLike;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type CurlHttpExecutor = {
|
||||
@@ -132,6 +133,7 @@ export function createCurlHttpExecutor(
|
||||
curlPath?: string;
|
||||
mkdir?: (targetPath: string) => Promise<unknown>;
|
||||
readFile?: (targetPath: string) => Promise<Buffer>;
|
||||
downloadTimeoutMs?: number;
|
||||
} = {},
|
||||
): CurlHttpExecutor {
|
||||
const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile);
|
||||
@@ -139,6 +141,7 @@ export function createCurlHttpExecutor(
|
||||
const mkdir =
|
||||
options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true }));
|
||||
const readFile = options.readFile ?? ((targetPath: string) => fs.promises.readFile(targetPath));
|
||||
const downloadTimeoutMs = options.downloadTimeoutMs ?? 120_000;
|
||||
|
||||
async function verifyDownloadedFile(destination: string, downloadOptions: CurlDownloadOptions) {
|
||||
if (!downloadOptions.sha512 && !downloadOptions.sha2) return;
|
||||
@@ -181,7 +184,8 @@ export function createCurlHttpExecutor(
|
||||
},
|
||||
async download(url, destination, downloadOptions): Promise<string> {
|
||||
await mkdir(path.dirname(destination));
|
||||
const args = buildBaseArgs();
|
||||
const timeout = downloadOptions.timeout ?? downloadTimeoutMs;
|
||||
const args = buildBaseArgs(timeout);
|
||||
addHeaderArgs(args, downloadOptions.headers);
|
||||
args.push('--output', destination, url.href);
|
||||
await runCurl<Buffer>({
|
||||
@@ -190,13 +194,15 @@ export function createCurlHttpExecutor(
|
||||
args,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout,
|
||||
cancellationToken: downloadOptions.cancellationToken,
|
||||
});
|
||||
await verifyDownloadedFile(destination, downloadOptions);
|
||||
return destination;
|
||||
},
|
||||
async downloadToBuffer(url, downloadOptions): Promise<Buffer> {
|
||||
const args = buildBaseArgs();
|
||||
const timeout = downloadOptions.timeout ?? downloadTimeoutMs;
|
||||
const args = buildBaseArgs(timeout);
|
||||
addHeaderArgs(args, downloadOptions.headers);
|
||||
args.push(url.href);
|
||||
return await runCurl<Buffer>({
|
||||
@@ -205,6 +211,7 @@ export function createCurlHttpExecutor(
|
||||
args,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 600 * 1024 * 1024,
|
||||
timeout,
|
||||
cancellationToken: downloadOptions.cancellationToken,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElectronNetFetch } from './fetch-adapter';
|
||||
import { createElectronNetFetch, createGlobalFetch } from './fetch-adapter';
|
||||
import type { FetchResponseLike } from './release-assets';
|
||||
|
||||
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
|
||||
@@ -33,3 +33,32 @@ test('createElectronNetFetch delegates updater requests to Electron net.fetch',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('createGlobalFetch delegates updater requests to main-process fetch', async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const response: FetchResponseLike = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ ok: true }),
|
||||
text: async () => 'ok',
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
|
||||
const fetch = createGlobalFetch(async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return response;
|
||||
});
|
||||
|
||||
const result = await fetch('https://api.github.com/repos/ksyasuda/SubMiner/releases', {
|
||||
headers: { 'User-Agent': 'SubMiner updater' },
|
||||
});
|
||||
|
||||
assert.equal(result, response);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
url: 'https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||
init: { headers: { 'User-Agent': 'SubMiner updater' } } as RequestInit,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,19 @@ export interface ElectronNetFetchLike {
|
||||
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
||||
}
|
||||
|
||||
export type GlobalFetchLike = (url: string, init?: RequestInit) => Promise<FetchResponseLike>;
|
||||
|
||||
export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike {
|
||||
return (url, init) => net.fetch(url, init);
|
||||
}
|
||||
|
||||
function getGlobalFetch(): GlobalFetchLike {
|
||||
if (typeof globalThis.fetch !== 'function') {
|
||||
throw new Error('Global fetch is not available for updater requests.');
|
||||
}
|
||||
return globalThis.fetch.bind(globalThis) as GlobalFetchLike;
|
||||
}
|
||||
|
||||
export function createGlobalFetch(fetchImpl?: GlobalFetchLike): FetchLike {
|
||||
return (url, init) => (fetchImpl ?? getGlobalFetch())(url, init as RequestInit);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
import { createFetchHttpExecutor } from './fetch-http-executor';
|
||||
|
||||
function neverCancel<T>(
|
||||
callback: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (error: Error) => void,
|
||||
onCancel: (callback: () => void) => void,
|
||||
) => void,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => callback(resolve, reject, () => {}));
|
||||
}
|
||||
|
||||
test('fetch HTTP executor requests updater metadata without Electron networking', async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const executor = createFetchHttpExecutor({
|
||||
fetch: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response('version: 0.15.0');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executor.request({
|
||||
protocol: 'https:',
|
||||
hostname: 'example.test',
|
||||
path: '/latest.yml',
|
||||
headers: { Accept: 'application/octet-stream' },
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
assert.equal(result, 'version: 0.15.0');
|
||||
assert.equal(calls[0]?.url, 'https://example.test/latest.yml');
|
||||
assert.equal((calls[0]?.init?.headers as Headers).get('Accept'), 'application/octet-stream');
|
||||
assert.equal(calls[0]?.init?.method, 'GET');
|
||||
});
|
||||
|
||||
test('fetch HTTP executor downloads updater assets to the requested destination', async () => {
|
||||
const data = Buffer.from('installer');
|
||||
const written: Array<{ path: string; data: Buffer }> = [];
|
||||
const executor = createFetchHttpExecutor({
|
||||
fetch: async () => new Response(new Uint8Array(data)),
|
||||
mkdir: async () => undefined,
|
||||
writeFile: async (targetPath, body) => {
|
||||
written.push({ path: targetPath, data: body });
|
||||
},
|
||||
});
|
||||
|
||||
const destination = await executor.download(
|
||||
new URL('https://example.test/SubMiner-0.15.0.exe'),
|
||||
'C:\\Temp\\SubMiner-0.15.0.exe',
|
||||
{
|
||||
cancellationToken: {
|
||||
createPromise: neverCancel,
|
||||
},
|
||||
sha2: createHash('sha256').update(data).digest('hex'),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(destination, 'C:\\Temp\\SubMiner-0.15.0.exe');
|
||||
assert.deepEqual(written, [{ path: destination, data }]);
|
||||
});
|
||||
|
||||
test('fetch HTTP executor verifies updater asset hashes', async () => {
|
||||
const executor = createFetchHttpExecutor({
|
||||
fetch: async () => new Response('wrong data'),
|
||||
mkdir: async () => undefined,
|
||||
writeFile: async () => {
|
||||
throw new Error('should not write mismatched data');
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
executor.download(new URL('https://example.test/SubMiner.exe'), 'C:\\Temp\\SubMiner.exe', {
|
||||
cancellationToken: {
|
||||
createPromise: neverCancel,
|
||||
},
|
||||
sha2: createHash('sha256').update('expected data').digest('hex'),
|
||||
}),
|
||||
/sha2 mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch HTTP executor applies download timeout to updater asset fetches', async () => {
|
||||
const executor = createFetchHttpExecutor({
|
||||
downloadTimeoutMs: 1,
|
||||
fetch: async (_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('download aborted')), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
mkdir: async () => undefined,
|
||||
writeFile: async () => {
|
||||
throw new Error('should not write timed-out data');
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
executor.download(new URL('https://example.test/SubMiner.exe'), 'C:\\Temp\\SubMiner.exe', {
|
||||
cancellationToken: {
|
||||
createPromise: neverCancel,
|
||||
},
|
||||
}),
|
||||
/download aborted/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch HTTP executor applies download timeout to buffer fetches', async () => {
|
||||
const executor = createFetchHttpExecutor({
|
||||
downloadTimeoutMs: 1,
|
||||
fetch: async (_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('buffer aborted')), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
executor.downloadToBuffer(new URL('https://example.test/SubMiner.exe'), {
|
||||
cancellationToken: {
|
||||
createPromise: neverCancel,
|
||||
},
|
||||
}),
|
||||
/buffer aborted/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { OutgoingHttpHeaders, RequestOptions } from 'node:http';
|
||||
|
||||
type CancellationTokenLike = {
|
||||
createPromise: <T>(
|
||||
callback: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (error: Error) => void,
|
||||
onCancel: (callback: () => void) => void,
|
||||
) => void,
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
type FetchDownloadOptions = {
|
||||
headers?: OutgoingHttpHeaders | null;
|
||||
sha2?: string | null;
|
||||
sha512?: string | null;
|
||||
cancellationToken: CancellationTokenLike;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type FetchHttpExecutor = {
|
||||
request: (
|
||||
options: RequestOptions,
|
||||
cancellationToken?: CancellationTokenLike,
|
||||
data?: Record<string, unknown> | null,
|
||||
) => Promise<string | null>;
|
||||
download: (url: URL, destination: string, options: FetchDownloadOptions) => Promise<string>;
|
||||
downloadToBuffer: (url: URL, options: FetchDownloadOptions) => Promise<Buffer>;
|
||||
};
|
||||
|
||||
type FetchImpl = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
function requestOptionsToUrl(options: RequestOptions): string {
|
||||
const protocol = options.protocol ?? 'https:';
|
||||
const hostname = options.hostname ?? options.host;
|
||||
if (!hostname) throw new Error('Updater request is missing a hostname.');
|
||||
const port = options.port ? `:${options.port}` : '';
|
||||
const requestPath = options.path ?? '/';
|
||||
return `${protocol}//${hostname}${port}${requestPath}`;
|
||||
}
|
||||
|
||||
function toHeaders(headers: RequestOptions['headers'] | OutgoingHttpHeaders | null | undefined) {
|
||||
const result = new Headers();
|
||||
if (Array.isArray(headers)) {
|
||||
for (let index = 0; index < headers.length; index += 2) {
|
||||
const name = headers[index];
|
||||
const value = headers[index + 1];
|
||||
if (name !== undefined && value !== undefined) {
|
||||
result.append(String(name), String(value));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
for (const [name, value] of Object.entries(headers ?? {})) {
|
||||
if (value === undefined || value === null) continue;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const item of values) {
|
||||
result.append(name, String(item));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runWithCancellation<T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
cancellationToken?: CancellationTokenLike,
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const run = (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (error: Error) => void,
|
||||
onCancel: (callback: () => void) => void,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
const timeout =
|
||||
typeof timeoutMs === 'number' && timeoutMs > 0
|
||||
? setTimeout(() => controller.abort(), timeoutMs)
|
||||
: null;
|
||||
onCancel(() => {
|
||||
controller.abort();
|
||||
});
|
||||
operation(controller.signal)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
});
|
||||
};
|
||||
|
||||
if (cancellationToken) {
|
||||
return cancellationToken.createPromise<T>(run);
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => run(resolve, reject, () => {}));
|
||||
}
|
||||
|
||||
async function fetchBuffer(
|
||||
fetchImpl: FetchImpl,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
cancellationToken?: CancellationTokenLike,
|
||||
timeoutMs?: number,
|
||||
): Promise<Buffer> {
|
||||
const response = await runWithCancellation(
|
||||
(signal) => fetchImpl(url, { ...init, signal }),
|
||||
cancellationToken,
|
||||
timeoutMs,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Updater request failed with ${response.status}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function verifyDownloadedData(data: Buffer, downloadOptions: FetchDownloadOptions) {
|
||||
if (downloadOptions.sha512) {
|
||||
const actual = createHash('sha512').update(data).digest('base64');
|
||||
if (actual !== downloadOptions.sha512) {
|
||||
throw new Error(`sha512 mismatch: expected ${downloadOptions.sha512}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
if (downloadOptions.sha2) {
|
||||
const actual = createHash('sha256').update(data).digest('hex');
|
||||
if (actual !== downloadOptions.sha2.toLowerCase()) {
|
||||
throw new Error(`sha2 mismatch: expected ${downloadOptions.sha2}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFetchHttpExecutor(
|
||||
options: {
|
||||
fetch?: FetchImpl;
|
||||
mkdir?: (targetPath: string) => Promise<unknown>;
|
||||
writeFile?: (targetPath: string, data: Buffer) => Promise<unknown>;
|
||||
downloadTimeoutMs?: number;
|
||||
} = {},
|
||||
): FetchHttpExecutor {
|
||||
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
const mkdir =
|
||||
options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true }));
|
||||
const writeFile =
|
||||
options.writeFile ??
|
||||
((targetPath: string, data: Buffer) => fs.promises.writeFile(targetPath, data));
|
||||
const downloadTimeoutMs = options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
||||
|
||||
return {
|
||||
async request(requestOptions, cancellationToken, data): Promise<string | null> {
|
||||
const headers = toHeaders(requestOptions.headers);
|
||||
const body = data ? JSON.stringify(data) : undefined;
|
||||
const result = await fetchBuffer(
|
||||
fetchImpl,
|
||||
requestOptionsToUrl(requestOptions),
|
||||
{
|
||||
method: requestOptions.method ?? (body ? 'POST' : 'GET'),
|
||||
headers,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
},
|
||||
cancellationToken,
|
||||
requestOptions.timeout,
|
||||
);
|
||||
return result.length === 0 ? null : result.toString('utf8');
|
||||
},
|
||||
async download(url, destination, downloadOptions): Promise<string> {
|
||||
await mkdir(path.dirname(destination));
|
||||
const data = await fetchBuffer(
|
||||
fetchImpl,
|
||||
url.href,
|
||||
{
|
||||
headers: toHeaders(downloadOptions.headers),
|
||||
redirect: 'follow',
|
||||
},
|
||||
downloadOptions.cancellationToken,
|
||||
downloadOptions.timeout ?? downloadTimeoutMs,
|
||||
);
|
||||
verifyDownloadedData(data, downloadOptions);
|
||||
await writeFile(destination, data);
|
||||
return destination;
|
||||
},
|
||||
async downloadToBuffer(url, downloadOptions): Promise<Buffer> {
|
||||
const data = await fetchBuffer(
|
||||
fetchImpl,
|
||||
url.href,
|
||||
{
|
||||
headers: toHeaders(downloadOptions.headers),
|
||||
redirect: 'follow',
|
||||
},
|
||||
downloadOptions.cancellationToken,
|
||||
downloadOptions.timeout ?? downloadTimeoutMs,
|
||||
);
|
||||
verifyDownloadedData(data, downloadOptions);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
const JELLYFIN_SETUP_SUBMIT_CHANNEL = 'jellyfin:setup-submit';
|
||||
|
||||
type JellyfinSetupAction = 'login' | 'logout' | 'done';
|
||||
|
||||
type JellyfinSetupSubmission = {
|
||||
action?: unknown;
|
||||
server?: unknown;
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function normalizeAction(value: unknown): JellyfinSetupAction {
|
||||
return value === 'logout' || value === 'done' ? value : 'login';
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function normalizeSubmission(value: unknown): Required<JellyfinSetupSubmission> {
|
||||
const record = isRecord(value) ? value : {};
|
||||
return {
|
||||
action: normalizeAction(record.action),
|
||||
server: normalizeString(record.server),
|
||||
username: normalizeString(record.username),
|
||||
password: normalizeString(record.password),
|
||||
};
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('subminerJellyfinSetup', {
|
||||
submit: (submission: JellyfinSetupSubmission): Promise<unknown> =>
|
||||
ipcRenderer.invoke(JELLYFIN_SETUP_SUBMIT_CHANNEL, normalizeSubmission(submission)),
|
||||
});
|
||||
@@ -71,6 +71,7 @@ export const IPC_CHANNELS = {
|
||||
openAnilistSetup: 'anilist:open-setup',
|
||||
getAnilistQueueStatus: 'anilist:get-queue-status',
|
||||
retryAnilistNow: 'anilist:retry-now',
|
||||
jellyfinSetupSubmit: 'jellyfin:setup-submit',
|
||||
getCharacterDictionarySelection: 'character-dictionary:get-selection',
|
||||
setCharacterDictionarySelection: 'character-dictionary:set-selection',
|
||||
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
|
||||
|
||||
Reference in New Issue
Block a user