mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
Fix Jellyfin Login (#76)
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user