refactor(main): extract update service runtime from main.ts

This commit is contained in:
2026-06-11 22:39:05 -07:00
parent 94a65416ae
commit 0ef95cde09
2 changed files with 206 additions and 173 deletions
@@ -0,0 +1,192 @@
import { app, dialog } from 'electron';
import { execFile } from 'node:child_process';
import path from 'node:path';
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
import type { OverlayNotificationPayload } from '../../../types/notification';
import { createElectronAppUpdater, isNativeUpdaterSupported } from './app-updater';
import { createCurlFetch, createGlobalFetch } from './fetch-adapter';
import { createCurlHttpExecutor } from './curl-http-executor';
import { createFetchHttpExecutor } from './fetch-http-executor';
import {
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseSha256Sums,
type GitHubRelease,
} from './release-assets';
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
import { updateLauncherFromRelease } from './launcher-updater';
import { notifyUpdateAvailable } from './update-notifications';
import { createUpdateDialogPresenter } from './update-dialogs';
import { createFileUpdateStateStore, createUpdateService } from './update-service';
import { updateSupportAssetsFromRelease } from './support-assets';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
export interface UpdateServiceRuntimeDeps {
userDataPath: string;
getUpdatesConfig: () => Required<UpdatesConfig>;
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
showMpvOsd: (message: string) => void;
withStatsWindowLayerSuspendedForNativeDialog: <T>(showDialog: () => Promise<T>) => Promise<T>;
}
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
getUpdateService: () => ReturnType<typeof createUpdateService>;
} {
const updateStateStore = createFileUpdateStateStore(
path.join(deps.userDataPath, 'update-state.json'),
);
let updateService: ReturnType<typeof createUpdateService> | null = null;
const globalFetchForUpdater = createGlobalFetch();
const curlFetch = createCurlFetch();
function createNativeUpdaterHttpExecutor() {
if (process.platform === 'win32') {
return createFetchHttpExecutor();
}
return createCurlHttpExecutor();
}
function getFetchForUpdater() {
if (process.platform === 'win32') return globalFetchForUpdater;
return curlFetch;
}
async function updateLauncherFromSelectedRelease(
launcherPath?: string,
channel: UpdateChannel = deps.getUpdatesConfig().channel,
release: GitHubRelease | null = null,
) {
const fetchForUpdater = getFetchForUpdater();
if (!release) {
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
}
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
if (!sumsAsset) {
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
}
const sums = parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
);
const launcherResult = await updateLauncherFromRelease({
release,
sha256Sums: sums,
launcherPath,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
});
const supportResults = await updateSupportAssetsFromRelease({
release,
sha256Sums: sums,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
});
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
deps.logWarn(`Rofi theme update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
deps.logWarn(`Rofi theme update skipped: ${result.message ?? result.status}`);
}
}
return launcherResult;
}
function getUpdateService() {
if (updateService) return updateService;
const appUpdater = createElectronAppUpdater({
currentVersion: app.getVersion(),
isPackaged: app.isPackaged,
log: (message) => deps.logInfo(message),
getChannel: () => deps.getUpdatesConfig().channel,
configureHttpExecutor: createNativeUpdaterHttpExecutor,
disableDifferentialDownload: true,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
isPackaged: app.isPackaged,
execPath: process.execPath,
env: process.env,
log: (message) => deps.logWarn(message),
}),
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: async () => {
if (process.platform !== 'darwin') {
app.focus({ steal: true });
return;
}
try {
await app.dock?.show();
} catch (error) {
deps.logWarn('Failed to show macOS dock before update dialog', error);
}
// app.focus({ steal: true }) alone does not reliably activate the process
// when SubMiner was reached via `subminer -u` (single-instance forwarding
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
// which is the only path that reliably brings the running app forward.
await new Promise<void>((resolve) => {
execFile(
'/usr/bin/osascript',
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
{ timeout: 2000 },
(error) => {
if (error) {
deps.logWarn(
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
);
}
resolve();
},
);
});
app.focus({ steal: true });
},
withStatsWindowLayerSuspended: (showDialog) =>
deps.withStatsWindowLayerSuspendedForNativeDialog(showDialog),
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
getConfig: () => deps.getUpdatesConfig(),
getCurrentVersion: () => app.getVersion(),
now: () => Date.now(),
readState: () => updateStateStore.readState(),
writeState: (state) => updateStateStore.writeState(state),
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
fetchLatestStableRelease: (channel) =>
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel, release) =>
updateLauncherFromSelectedRelease(launcherPath, channel, release),
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
showUpdateAvailableDialog: (version) =>
updateDialogPresenter.showUpdateAvailableDialog(version),
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
showManualUpdateRequiredDialog: (version) =>
updateDialogPresenter.showManualUpdateRequiredDialog(version),
downloadAppUpdate: () => appUpdater.downloadUpdate(),
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
quitAndInstall: () => appUpdater.quitAndInstall(),
notifyUpdateAvailable: (version) =>
notifyUpdateAvailable(
{ notificationType: deps.getUpdatesConfig().notificationType, version },
{
showSystemNotification: (title, body) => deps.showDesktopNotification(title, { body }),
showOverlayNotification: (payload) => deps.showOverlayNotification(payload),
showOsdNotification: (message) => {
deps.showMpvOsd(message);
},
log: (message) => deps.logWarn(message),
},
),
log: (message) => deps.logWarn(message),
});
return updateService;
}
return { getUpdateService };
}