mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-15 20:12:59 -07:00
fix: guard native updater against unsupported builds to prevent crashes
- Skip electron-updater on ad-hoc signed macOS, package-managed Linux AppImages, non-writable AppImages, and Windows - Preserve GitHub metadata checks and direct AppImage updates for supported installs - Add isNativeUpdaterSupported with full platform/signing/writability test coverage
This commit is contained in:
+12
-1
@@ -508,7 +508,10 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import { createElectronAppUpdater } from './main/runtime/update/app-updater';
|
||||
import {
|
||||
createElectronAppUpdater,
|
||||
isNativeUpdaterSupported,
|
||||
} from './main/runtime/update/app-updater';
|
||||
import {
|
||||
fetchLatestStableRelease,
|
||||
fetchReleaseAssetBuffer,
|
||||
@@ -4658,6 +4661,14 @@ function getUpdateService() {
|
||||
isPackaged: app.isPackaged,
|
||||
log: (message) => logger.info(message),
|
||||
getChannel: () => getResolvedConfig().updates.channel,
|
||||
isNativeUpdaterSupported: () =>
|
||||
isNativeUpdaterSupported({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
execPath: process.execPath,
|
||||
env: process.env,
|
||||
log: (message) => logger.warn(message),
|
||||
}),
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
getConfig: () => getResolvedConfig().updates,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
|
||||
import {
|
||||
configureAutoUpdater,
|
||||
createElectronAppUpdater,
|
||||
isKnownLinuxPackageManagedAppImage,
|
||||
isNativeUpdaterSupported,
|
||||
resolveMacAppBundlePath,
|
||||
type ElectronAutoUpdaterLike,
|
||||
} from './app-updater';
|
||||
|
||||
type UpdaterLogger = {
|
||||
info: (message: string) => void;
|
||||
@@ -53,3 +60,192 @@ test('configureAutoUpdater allows prereleases only for the prerelease channel',
|
||||
configureAutoUpdater(updater, () => {}, 'stable');
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
});
|
||||
|
||||
test('app updater skips native update checks when native updater is unsupported', async () => {
|
||||
let checked = false;
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => {
|
||||
checked = true;
|
||||
return {
|
||||
updateInfo: {
|
||||
version: '0.15.0',
|
||||
},
|
||||
};
|
||||
},
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const logged: string[] = [];
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: (message) => logged.push(message),
|
||||
isNativeUpdaterSupported: () => false,
|
||||
});
|
||||
|
||||
const result = await appUpdater.checkForUpdates('stable');
|
||||
|
||||
assert.equal(checked, false);
|
||||
assert.deepEqual(result, {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
});
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native app update check because native updater is unsupported.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('app updater skips native downloads when native updater is unsupported', async () => {
|
||||
let downloaded = false;
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => {
|
||||
downloaded = true;
|
||||
return [];
|
||||
},
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const logged: string[] = [];
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: (message) => logged.push(message),
|
||||
isNativeUpdaterSupported: () => false,
|
||||
});
|
||||
|
||||
await appUpdater.downloadUpdate();
|
||||
|
||||
assert.equal(downloaded, false);
|
||||
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
|
||||
});
|
||||
|
||||
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
|
||||
assert.equal(
|
||||
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||
'/Applications/SubMiner.app',
|
||||
);
|
||||
assert.equal(resolveMacAppBundlePath('/usr/local/bin/SubMiner'), null);
|
||||
});
|
||||
|
||||
test('mac native updater is unsupported for ad-hoc signed app bundles', () => {
|
||||
const logged: string[] = [];
|
||||
const supported = isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
readCodeSignature: () =>
|
||||
['Signature=adhoc', 'TeamIdentifier=not set', 'Runtime Version=26.0.0'].join('\n'),
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
||||
});
|
||||
|
||||
test('mac native updater is supported for Developer ID signed app bundles', () => {
|
||||
const supported = isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
readCodeSignature: () =>
|
||||
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
|
||||
});
|
||||
|
||||
assert.equal(supported, true);
|
||||
});
|
||||
|
||||
test('linux native updater is supported for writable direct AppImage installs', () => {
|
||||
const supported = isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
canWriteAppImage: (appImagePath) =>
|
||||
appImagePath === '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
});
|
||||
|
||||
assert.equal(supported, true);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported when APPIMAGE is missing', () => {
|
||||
const logged: string[] = [];
|
||||
const supported = isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, ['Skipping native Linux updater because APPIMAGE is not set.']);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for non-writable AppImage installs', () => {
|
||||
const logged: string[] = [];
|
||||
const supported = isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
canWriteAppImage: () => false,
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because the running AppImage is not writable.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for package-managed AppImage installs', () => {
|
||||
const logged: string[] = [];
|
||||
const supported = isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/opt/SubMiner/SubMiner.AppImage',
|
||||
},
|
||||
canWriteAppImage: () => true,
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because this AppImage is managed by the system package manager.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('known Linux package-managed AppImage detection follows the canonical AUR path', () => {
|
||||
assert.equal(isKnownLinuxPackageManagedAppImage('/opt/SubMiner/SubMiner.AppImage'), true);
|
||||
assert.equal(
|
||||
isKnownLinuxPackageManagedAppImage('/home/tester/.local/bin/SubMiner.AppImage'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('native updater is unsupported on Windows by default', () => {
|
||||
const supported = isNativeUpdaterSupported({
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { constants, accessSync, realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from './release-assets';
|
||||
@@ -29,6 +32,103 @@ export interface ElectronAutoUpdaterLike {
|
||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
||||
}
|
||||
|
||||
export function resolveMacAppBundlePath(execPath: string): string | null {
|
||||
const marker = '.app/Contents/MacOS/';
|
||||
const markerIndex = execPath.indexOf(marker);
|
||||
if (markerIndex < 0) return null;
|
||||
return execPath.slice(0, markerIndex + '.app'.length);
|
||||
}
|
||||
|
||||
function readMacCodeSignature(appBundlePath: string): string | null {
|
||||
const result = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
}
|
||||
|
||||
function canWriteLinuxAppImage(appImagePath: string): boolean {
|
||||
try {
|
||||
accessSync(appImagePath, constants.W_OK);
|
||||
accessSync(path.dirname(appImagePath), constants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function realpathOrOriginal(filePath: string): string {
|
||||
try {
|
||||
return realpathSync(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean {
|
||||
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
|
||||
}
|
||||
|
||||
export function isNativeUpdaterSupported(options: {
|
||||
platform: NodeJS.Platform;
|
||||
isPackaged: boolean;
|
||||
execPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
canWriteAppImage?: (appImagePath: string) => boolean;
|
||||
readCodeSignature?: (appBundlePath: string) => string | null;
|
||||
log?: (message: string) => void;
|
||||
}): boolean {
|
||||
if (!options.isPackaged) {
|
||||
options.log?.('Skipping native updater because this build is not packaged.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
const appImagePath = options.env?.APPIMAGE?.trim();
|
||||
if (!appImagePath) {
|
||||
options.log?.('Skipping native Linux updater because APPIMAGE is not set.');
|
||||
return false;
|
||||
}
|
||||
if (isKnownLinuxPackageManagedAppImage(appImagePath)) {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because this AppImage is managed by the system package manager.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!(options.canWriteAppImage ?? canWriteLinuxAppImage)(appImagePath)) {
|
||||
options.log?.('Skipping native Linux updater because the running AppImage is not writable.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (options.platform !== 'darwin') {
|
||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const appBundlePath = resolveMacAppBundlePath(options.execPath);
|
||||
if (!appBundlePath) {
|
||||
options.log?.(
|
||||
'Skipping native macOS updater because the app bundle path could not be resolved.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const signature = (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
|
||||
if (!signature) {
|
||||
options.log?.(
|
||||
'Skipping native macOS updater because the app code signature could not be read.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
|
||||
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function configureAutoUpdater(
|
||||
updater: ElectronAutoUpdaterLike,
|
||||
log: (message: string) => void = () => {},
|
||||
@@ -52,6 +152,7 @@ export function createElectronAppUpdater(options: {
|
||||
updater?: ElectronAutoUpdaterLike;
|
||||
log: (message: string) => void;
|
||||
getChannel?: () => UpdateChannel;
|
||||
isNativeUpdaterSupported?: () => boolean;
|
||||
}) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||
const updater = configureAutoUpdater(
|
||||
@@ -59,6 +160,15 @@ export function createElectronAppUpdater(options: {
|
||||
options.log,
|
||||
getChannel(),
|
||||
);
|
||||
let nativeUpdaterSupported: boolean | null = null;
|
||||
|
||||
function isNativeUpdaterSupported(): boolean {
|
||||
if (!options.isNativeUpdaterSupported) return true;
|
||||
if (nativeUpdaterSupported === null) {
|
||||
nativeUpdaterSupported = options.isNativeUpdaterSupported();
|
||||
}
|
||||
return nativeUpdaterSupported;
|
||||
}
|
||||
|
||||
return {
|
||||
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
|
||||
@@ -69,6 +179,14 @@ export function createElectronAppUpdater(options: {
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
if (!isNativeUpdaterSupported()) {
|
||||
options.log('Skipping native app update check because native updater is unsupported.');
|
||||
return {
|
||||
available: false,
|
||||
version: options.currentVersion,
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
configureAutoUpdater(updater, options.log, channel ?? getChannel());
|
||||
const result = await updater.checkForUpdates();
|
||||
const version = result?.updateInfo?.version ?? options.currentVersion;
|
||||
@@ -83,9 +201,21 @@ export function createElectronAppUpdater(options: {
|
||||
options.log('Skipping app update download because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!isNativeUpdaterSupported()) {
|
||||
options.log('Skipping app update download because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
await updater.downloadUpdate();
|
||||
},
|
||||
quitAndInstall(): void {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update install because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!isNativeUpdaterSupported()) {
|
||||
options.log('Skipping app update install because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
updater.quitAndInstall(false, true);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -90,6 +90,32 @@ test('manual update check falls back to GitHub release when app metadata is unav
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update check updates launcher without native download when native updater is unavailable', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
updateLauncher: async (_launcherPath, channel) => {
|
||||
calls.push(`launcher:${channel}`);
|
||||
return { status: 'skipped' };
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable', 'restart-dialog']);
|
||||
});
|
||||
|
||||
test('automatic update check skips inside configured interval', async () => {
|
||||
const { deps, calls, setState } = createDeps();
|
||||
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
|
||||
|
||||
Reference in New Issue
Block a user