mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(macos): configuration window + curl-backed macOS updater (#71)
This commit is contained in:
@@ -162,6 +162,46 @@ test('app updater skips native downloads when native updater is unsupported', as
|
||||
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
|
||||
});
|
||||
|
||||
test('app updater installs a custom HTTP executor before native checks', async () => {
|
||||
const httpExecutor = { request: async () => null };
|
||||
let executorDuringCheck: unknown;
|
||||
let differentialDownloadDuringCheck: unknown;
|
||||
const updater: ElectronAutoUpdaterLike & {
|
||||
httpExecutor?: unknown;
|
||||
disableDifferentialDownload?: boolean;
|
||||
} = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => {
|
||||
executorDuringCheck = updater.httpExecutor;
|
||||
differentialDownloadDuringCheck = updater.disableDifferentialDownload;
|
||||
return {
|
||||
updateInfo: {
|
||||
version: '0.15.0',
|
||||
},
|
||||
};
|
||||
},
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: () => {},
|
||||
configureHttpExecutor: () => httpExecutor,
|
||||
disableDifferentialDownload: true,
|
||||
});
|
||||
|
||||
const result = await appUpdater.checkForUpdates('stable');
|
||||
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(executorDuringCheck, httpExecutor);
|
||||
assert.equal(differentialDownloadDuringCheck, true);
|
||||
});
|
||||
|
||||
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
|
||||
assert.equal(
|
||||
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||
@@ -185,6 +225,25 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async ()
|
||||
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
||||
});
|
||||
|
||||
test('mac native updater is unsupported outside Applications folders before signature probing', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Users/tester/build/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
homeDir: '/Users/tester',
|
||||
readCodeSignature: () => {
|
||||
throw new Error('signature should not be read');
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native macOS updater because the app is not installed in an Applications folder.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mac native updater supports Developer ID signed packaged app bundles', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
@@ -34,11 +36,16 @@ export interface ElectronAutoUpdaterLike {
|
||||
} | null>;
|
||||
downloadUpdate: () => Promise<unknown>;
|
||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
||||
disableDifferentialDownload?: boolean;
|
||||
}
|
||||
|
||||
const updaterErrorListeners = new WeakMap<object, (error: unknown) => void>();
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type ElectronAutoUpdaterWithHttpExecutor = ElectronAutoUpdaterLike & {
|
||||
httpExecutor?: unknown;
|
||||
};
|
||||
|
||||
export function resolveMacAppBundlePath(execPath: string): string | null {
|
||||
const marker = '.app/Contents/MacOS/';
|
||||
const markerIndex = execPath.indexOf(marker);
|
||||
@@ -65,6 +72,25 @@ function realpathOrOriginal(filePath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isSameOrInsideDirectory(parentPath: string, candidatePath: string): boolean {
|
||||
const relative = path.relative(parentPath, candidatePath);
|
||||
return (
|
||||
relative === '' ||
|
||||
(relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
export function isMacApplicationsFolderBundle(
|
||||
appBundlePath: string,
|
||||
homeDir: string = os.homedir(),
|
||||
): boolean {
|
||||
const resolvedBundlePath = path.resolve(appBundlePath);
|
||||
return (
|
||||
isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
|
||||
isSameOrInsideDirectory(path.join(homeDir, 'Applications'), resolvedBundlePath)
|
||||
);
|
||||
}
|
||||
|
||||
export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean {
|
||||
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
|
||||
}
|
||||
@@ -74,6 +100,7 @@ export async function isNativeUpdaterSupported(options: {
|
||||
isPackaged: boolean;
|
||||
execPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
readCodeSignature?: (appBundlePath: string) => string | null | Promise<string | null>;
|
||||
log?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
@@ -100,6 +127,13 @@ export async function isNativeUpdaterSupported(options: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
|
||||
options.log?.(
|
||||
'Skipping native macOS updater because the app is not installed in an Applications folder.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
|
||||
if (!signature) {
|
||||
options.log?.(
|
||||
@@ -157,6 +191,8 @@ export function createElectronAppUpdater(options: {
|
||||
log: (message: string) => void;
|
||||
getChannel?: () => UpdateChannel;
|
||||
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
|
||||
configureHttpExecutor?: () => unknown;
|
||||
disableDifferentialDownload?: boolean;
|
||||
}) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||
const updater = configureAutoUpdater(
|
||||
@@ -164,6 +200,13 @@ export function createElectronAppUpdater(options: {
|
||||
options.log,
|
||||
getChannel(),
|
||||
);
|
||||
if (options.configureHttpExecutor) {
|
||||
// electron-updater has no public executor hook; keep the macOS cURL override localized.
|
||||
(updater as ElectronAutoUpdaterWithHttpExecutor).httpExecutor = options.configureHttpExecutor();
|
||||
}
|
||||
if (options.disableDifferentialDownload !== undefined) {
|
||||
updater.disableDifferentialDownload = options.disableDifferentialDownload;
|
||||
}
|
||||
let nativeUpdaterSupported: Promise<boolean> | null = null;
|
||||
|
||||
async function getNativeUpdaterSupported(): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createCurlHttpExecutor, type CurlExecFile } from './curl-http-executor';
|
||||
|
||||
test('curl HTTP executor requests updater metadata without Electron networking', async () => {
|
||||
const calls: Array<{ file: string; args: readonly string[] }> = [];
|
||||
const execFile: CurlExecFile = (file, args, _options, callback) => {
|
||||
calls.push({ file, args });
|
||||
queueMicrotask(() => callback(null, 'metadata', ''));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' });
|
||||
|
||||
const result = await executor.request({
|
||||
protocol: 'https:',
|
||||
hostname: 'api.github.com',
|
||||
path: '/repos/ksyasuda/SubMiner/releases',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'x-user-staging-id': 'abc',
|
||||
},
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
assert.equal(result, 'metadata');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.file, '/usr/bin/curl');
|
||||
assert.deepEqual(calls[0]?.args, [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'120',
|
||||
'--header',
|
||||
'Accept: application/vnd.github+json',
|
||||
'--header',
|
||||
'x-user-staging-id: abc',
|
||||
'https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||
]);
|
||||
});
|
||||
|
||||
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 });
|
||||
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({
|
||||
execFile,
|
||||
curlPath: '/usr/bin/curl',
|
||||
mkdir: async () => undefined,
|
||||
});
|
||||
|
||||
await executor.download(
|
||||
new URL('https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip'),
|
||||
'/tmp/subminer/update.zip',
|
||||
{
|
||||
headers: { 'User-Agent': 'SubMiner updater' },
|
||||
cancellationToken: {
|
||||
createPromise: (callback) =>
|
||||
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls[0]?.args, [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--header',
|
||||
'User-Agent: SubMiner updater',
|
||||
'--output',
|
||||
'/tmp/subminer/update.zip',
|
||||
'https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip',
|
||||
]);
|
||||
});
|
||||
|
||||
test('curl HTTP executor verifies downloaded updater asset hashes', async () => {
|
||||
const data = Buffer.from('zip payload');
|
||||
const expectedSha512 = createHash('sha512').update(data).digest('base64');
|
||||
const execFile: CurlExecFile = (_file, _args, _options, callback) => {
|
||||
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({
|
||||
execFile,
|
||||
curlPath: '/usr/bin/curl',
|
||||
mkdir: async () => undefined,
|
||||
readFile: async () => data,
|
||||
});
|
||||
|
||||
await executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', {
|
||||
sha512: expectedSha512,
|
||||
cancellationToken: {
|
||||
createPromise: (callback) =>
|
||||
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', {
|
||||
sha512: 'bad',
|
||||
cancellationToken: {
|
||||
createPromise: (callback) =>
|
||||
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
|
||||
},
|
||||
}),
|
||||
/sha512 mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
test('curl HTTP executor does not expose command arguments when stderr is empty', async () => {
|
||||
const execFile: CurlExecFile = (_file, _args, _options, callback) => {
|
||||
const error = new Error('--header Authorization: Bearer secret-token');
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
queueMicrotask(() => callback(error, '', ''));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.request({
|
||||
protocol: 'https:',
|
||||
hostname: 'api.github.com',
|
||||
path: '/repos/ksyasuda/SubMiner/releases',
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal(error.message, 'curl failed (ENOENT)');
|
||||
assert.doesNotMatch(error.message, /secret-token|Authorization/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { execFile as defaultExecFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { RequestOptions, OutgoingHttpHeaders } from 'node:http';
|
||||
|
||||
export type CurlExecFile = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: {
|
||||
encoding: 'utf8' | 'buffer';
|
||||
maxBuffer?: number;
|
||||
timeout?: number;
|
||||
},
|
||||
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void,
|
||||
) => { kill: (signal?: NodeJS.Signals) => unknown };
|
||||
|
||||
type CancellationTokenLike = {
|
||||
createPromise: <T>(
|
||||
callback: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (error: Error) => void,
|
||||
onCancel: (callback: () => void) => void,
|
||||
) => void,
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
type CurlDownloadOptions = {
|
||||
headers?: OutgoingHttpHeaders | null;
|
||||
sha2?: string | null;
|
||||
sha512?: string | null;
|
||||
cancellationToken: CancellationTokenLike;
|
||||
};
|
||||
|
||||
export type CurlHttpExecutor = {
|
||||
request: (
|
||||
options: RequestOptions,
|
||||
cancellationToken?: CancellationTokenLike,
|
||||
data?: Record<string, unknown> | null,
|
||||
) => Promise<string | null>;
|
||||
download: (url: URL, destination: string, options: CurlDownloadOptions) => Promise<string>;
|
||||
downloadToBuffer: (url: URL, options: CurlDownloadOptions) => Promise<Buffer>;
|
||||
};
|
||||
|
||||
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 addHeaderArgs(
|
||||
args: string[],
|
||||
headers: RequestOptions['headers'] | OutgoingHttpHeaders | null | undefined,
|
||||
): void {
|
||||
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) {
|
||||
args.push('--header', `${name}: ${value}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [name, value] of Object.entries(headers ?? {})) {
|
||||
if (value === undefined) continue;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const item of values) {
|
||||
args.push('--header', `${name}: ${String(item)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseArgs(timeoutMs?: number): string[] {
|
||||
const args = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30'];
|
||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
||||
args.push('--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function runCurl<T>(options: {
|
||||
execFile: CurlExecFile;
|
||||
curlPath: string;
|
||||
args: readonly string[];
|
||||
encoding: 'utf8' | 'buffer';
|
||||
maxBuffer?: number;
|
||||
timeout?: number;
|
||||
cancellationToken?: CancellationTokenLike;
|
||||
}): Promise<T> {
|
||||
const run = (
|
||||
resolve: (value: T) => void,
|
||||
reject: (error: Error) => void,
|
||||
onCancel: (callback: () => void) => void,
|
||||
) => {
|
||||
const child = options.execFile(
|
||||
options.curlPath,
|
||||
options.args,
|
||||
{
|
||||
encoding: options.encoding,
|
||||
maxBuffer: options.maxBuffer,
|
||||
timeout: options.timeout,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
const safeFallback = errno ? `curl failed (${errno})` : 'curl failed';
|
||||
reject(new Error(stderrMessage.trim() || safeFallback));
|
||||
return;
|
||||
}
|
||||
resolve(stdout as T);
|
||||
},
|
||||
);
|
||||
onCancel(() => {
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
};
|
||||
|
||||
if (options.cancellationToken) {
|
||||
return options.cancellationToken.createPromise<T>(run);
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => run(resolve, reject, () => {}));
|
||||
}
|
||||
|
||||
export function createCurlHttpExecutor(
|
||||
options: {
|
||||
execFile?: CurlExecFile;
|
||||
curlPath?: string;
|
||||
mkdir?: (targetPath: string) => Promise<unknown>;
|
||||
readFile?: (targetPath: string) => Promise<Buffer>;
|
||||
} = {},
|
||||
): CurlHttpExecutor {
|
||||
const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile);
|
||||
const curlPath = options.curlPath ?? '/usr/bin/curl';
|
||||
const mkdir =
|
||||
options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true }));
|
||||
const readFile = options.readFile ?? ((targetPath: string) => fs.promises.readFile(targetPath));
|
||||
|
||||
async function verifyDownloadedFile(destination: string, downloadOptions: CurlDownloadOptions) {
|
||||
if (!downloadOptions.sha512 && !downloadOptions.sha2) return;
|
||||
const data = await readFile(destination);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async request(requestOptions, cancellationToken, data): Promise<string | null> {
|
||||
const args = buildBaseArgs(requestOptions.timeout);
|
||||
addHeaderArgs(args, requestOptions.headers);
|
||||
if (requestOptions.method && requestOptions.method !== 'GET') {
|
||||
args.push('--request', requestOptions.method);
|
||||
}
|
||||
if (data) {
|
||||
args.push('--data-binary', JSON.stringify(data));
|
||||
}
|
||||
args.push(requestOptionsToUrl(requestOptions));
|
||||
const result = await runCurl<string>({
|
||||
execFile,
|
||||
curlPath,
|
||||
args,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: requestOptions.timeout,
|
||||
cancellationToken,
|
||||
});
|
||||
return result.length === 0 ? null : result;
|
||||
},
|
||||
async download(url, destination, downloadOptions): Promise<string> {
|
||||
await mkdir(path.dirname(destination));
|
||||
const args = buildBaseArgs();
|
||||
addHeaderArgs(args, downloadOptions.headers);
|
||||
args.push('--output', destination, url.href);
|
||||
await runCurl<Buffer>({
|
||||
execFile,
|
||||
curlPath,
|
||||
args,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 1024 * 1024,
|
||||
cancellationToken: downloadOptions.cancellationToken,
|
||||
});
|
||||
await verifyDownloadedFile(destination, downloadOptions);
|
||||
return destination;
|
||||
},
|
||||
async downloadToBuffer(url, downloadOptions): Promise<Buffer> {
|
||||
const args = buildBaseArgs();
|
||||
addHeaderArgs(args, downloadOptions.headers);
|
||||
args.push(url.href);
|
||||
return await runCurl<Buffer>({
|
||||
execFile,
|
||||
curlPath,
|
||||
args,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 600 * 1024 * 1024,
|
||||
cancellationToken: downloadOptions.cancellationToken,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElectronNetFetch } from './fetch-adapter';
|
||||
import type { FetchResponseLike } from './release-assets';
|
||||
|
||||
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
|
||||
const calls: Array<{ url: string; init?: Record<string, unknown> }> = [];
|
||||
const response: FetchResponseLike = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ ok: true }),
|
||||
text: async () => 'ok',
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
|
||||
const fetch = createElectronNetFetch({
|
||||
fetch: 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' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FetchLike, FetchResponseLike } from './release-assets';
|
||||
|
||||
export interface ElectronNetFetchLike {
|
||||
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
||||
}
|
||||
|
||||
export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike {
|
||||
return (url, init) => net.fetch(url, init);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||
|
||||
test('macOS release metadata fetch is skipped only when native updater is unsupported', () => {
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', {
|
||||
available: true,
|
||||
version: '0.15.0',
|
||||
canUpdate: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-macOS release metadata fetch is not gated by native updater support', () => {
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('linux', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('win32', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
type AppUpdateMetadata = {
|
||||
available: boolean;
|
||||
version: string;
|
||||
canUpdate?: boolean;
|
||||
};
|
||||
|
||||
export function shouldFetchReleaseMetadataForPlatform(
|
||||
platform: NodeJS.Platform,
|
||||
appUpdate: AppUpdateMetadata,
|
||||
): boolean {
|
||||
if (platform !== 'darwin') {
|
||||
return true;
|
||||
}
|
||||
return appUpdate.canUpdate !== false;
|
||||
}
|
||||
@@ -151,11 +151,72 @@ test('manual update check does not prompt restart when only launcher updates', a
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.deepEqual(calls, [
|
||||
'available-dialog:0.15.0',
|
||||
'launcher:stable',
|
||||
'manual-install:0.15.0',
|
||||
]);
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable', 'manual-install:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update check can skip release metadata after unsupported app updater', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||
shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false,
|
||||
fetchLatestStableRelease: async () => {
|
||||
calls.push('fetch-release');
|
||||
return {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
},
|
||||
} as Partial<UpdateServiceDeps>);
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.deepEqual(calls, ['no-update:0.14.0']);
|
||||
});
|
||||
|
||||
test('manual update check fetches release metadata after app metadata errors', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => {
|
||||
throw new Error('latest-mac.yml missing');
|
||||
},
|
||||
shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false,
|
||||
fetchLatestStableRelease: async () => {
|
||||
calls.push('fetch-release');
|
||||
return {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
},
|
||||
} as Partial<UpdateServiceDeps>);
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.deepEqual(calls, ['fetch-release', 'available-dialog:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update check reports non-Error failures safely', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
downloadAppUpdate: async () => {
|
||||
throw 'download rejected';
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.deepEqual(result, { status: 'failed', error: 'download rejected' });
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'failed:download rejected']);
|
||||
});
|
||||
|
||||
test('automatic update check skips inside configured interval', async () => {
|
||||
|
||||
@@ -30,15 +30,24 @@ export interface UpdateCheckResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type AppUpdateMetadata = {
|
||||
available: boolean;
|
||||
version: string;
|
||||
canUpdate?: boolean;
|
||||
};
|
||||
|
||||
export interface UpdateServiceDeps {
|
||||
getConfig: () => Required<UpdatesConfig>;
|
||||
getCurrentVersion: () => string;
|
||||
now: () => number;
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
checkAppUpdate: (
|
||||
channel: UpdateChannel,
|
||||
) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>;
|
||||
checkAppUpdate: (channel: UpdateChannel) => Promise<AppUpdateMetadata>;
|
||||
shouldFetchReleaseMetadata?: (input: {
|
||||
request: UpdateCheckRequest;
|
||||
channel: UpdateChannel;
|
||||
appUpdate: AppUpdateMetadata;
|
||||
}) => boolean;
|
||||
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
|
||||
updateLauncher: (
|
||||
launcherPath?: string,
|
||||
@@ -112,22 +121,23 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [appUpdate, release] = await Promise.all([
|
||||
deps.checkAppUpdate(channel).catch((error) => {
|
||||
if (isAutomatic) {
|
||||
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
version: deps.getCurrentVersion(),
|
||||
canUpdate: false,
|
||||
};
|
||||
}),
|
||||
deps.fetchLatestStableRelease(channel).catch((error) => {
|
||||
deps.log(`GitHub release update check failed: ${(error as Error).message}`);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
const appUpdate: AppUpdateMetadata = await deps.checkAppUpdate(channel).catch((error) => {
|
||||
if (isAutomatic) {
|
||||
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
version: deps.getCurrentVersion(),
|
||||
};
|
||||
});
|
||||
const shouldFetchReleaseMetadata =
|
||||
deps.shouldFetchReleaseMetadata?.({ request, channel, appUpdate }) ?? true;
|
||||
const release = shouldFetchReleaseMetadata
|
||||
? await deps.fetchLatestStableRelease(channel).catch((error) => {
|
||||
deps.log(`GitHub release update check failed: ${summarizeError(error)}`);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
const currentVersion = deps.getCurrentVersion();
|
||||
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
|
||||
|
||||
@@ -181,7 +191,7 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
}
|
||||
return { status: 'updated', version: latest.version };
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
const message = summarizeError(error);
|
||||
if (isAutomatic) {
|
||||
deps.log(`Automatic update check failed: ${message}`);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user