feat(macos): configuration window + curl-backed macOS updater (#71)

This commit is contained in:
2026-05-17 02:23:44 -07:00
committed by GitHub
parent 6ca5cede3e
commit e84674e3b5
100 changed files with 13890 additions and 235 deletions
@@ -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({
+43
View File
@@ -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' } },
},
]);
});
+9
View File
@@ -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;
}
+66 -5
View File
@@ -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 -20
View File
@@ -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 {