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
+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;
},
};
}