Fix Jellyfin Login (#76)

This commit is contained in:
2026-05-20 00:46:11 -07:00
committed by GitHub
parent 799cce6991
commit a54f03f0cd
31 changed files with 1087 additions and 148 deletions
@@ -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(),
+156 -17
View File
@@ -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'));
});
+125 -54
View File
@@ -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, '&quot;');
}
@@ -67,6 +76,18 @@ function escapeHtml(value: string): string {
.replace(/"/g, '&quot;');
}
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({
+2
View File
@@ -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 } : {}),
});
}
+2 -2
View File
@@ -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);
});
+3
View File
@@ -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,
});
},
+30 -1
View File
@@ -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,
},
]);
});
+13
View File
@@ -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;
},
};
}