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
+11 -170
View File
@@ -144,7 +144,6 @@ import type {
OverlayNotificationPayload, OverlayNotificationPayload,
OverlayNotificationEventPayload, OverlayNotificationEventPayload,
NotificationType, NotificationType,
UpdateChannel,
WindowGeometry, WindowGeometry,
} from './types'; } from './types';
import { OPEN_ANKI_CARD_ACTION_ID } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types';
@@ -589,26 +588,8 @@ import {
import { restoreLinuxOverlayWindowShape } from './main/runtime/linux-overlay-window-shape'; import { restoreLinuxOverlayWindowShape } from './main/runtime/linux-overlay-window-shape';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
import {
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseSha256Sums,
type GitHubRelease,
} from './main/runtime/update/release-assets';
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
import { import {
INSTALL_UPDATE_ACTION_ID, INSTALL_UPDATE_ACTION_ID,
notifyUpdateAvailable,
UPDATE_AVAILABLE_NOTIFICATION_ID, UPDATE_AVAILABLE_NOTIFICATION_ID,
} from './main/runtime/update/update-notifications'; } from './main/runtime/update/update-notifications';
import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd'; import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd';
@@ -621,16 +602,11 @@ import {
type ConfiguredStatusNotificationOptions, type ConfiguredStatusNotificationOptions,
} from './main/runtime/configured-status-notification'; } from './main/runtime/configured-status-notification';
import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing'; import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing';
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
import { import {
runUpdateCliCommand, runUpdateCliCommand,
writeUpdateCliCommandResponse, writeUpdateCliCommandResponse,
} from './main/runtime/update/update-cli-command'; } from './main/runtime/update/update-cli-command';
import { import { createUpdateServiceRuntime } from './main/runtime/update/update-service-runtime';
createFileUpdateStateStore,
createUpdateService,
} from './main/runtime/update/update-service';
import { updateSupportAssetsFromRelease } from './main/runtime/update/support-assets';
import { import {
createRefreshSubtitlePrefetchFromActiveTrackHandler, createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler, createResolveActiveSubtitleSidebarSourceHandler,
@@ -743,7 +719,6 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const TRAY_TOOLTIP = 'SubMiner'; const TRAY_TOOLTIP = 'SubMiner';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js'); const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState = let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
@@ -6401,153 +6376,19 @@ flushPendingMpvLogWrites = () => {
void flushMpvLog(); void flushMpvLog();
}; };
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json')); const { getUpdateService } = createUpdateServiceRuntime({
let updateService: ReturnType<typeof createUpdateService> | null = null; userDataPath: USER_DATA_PATH,
const globalFetchForUpdater = createGlobalFetch(); getUpdatesConfig: () => getResolvedConfig().updates,
const curlFetch = createCurlFetch(); logInfo: (message) => logger.info(message),
logWarn: (message, details) => logger.warn(message, details),
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 = getResolvedConfig().updates.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) {
logger.warn(`Rofi theme update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
logger.warn(`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) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
configureHttpExecutor: createNativeUpdaterHttpExecutor,
disableDifferentialDownload: true,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
isPackaged: app.isPackaged,
execPath: process.execPath,
env: process.env,
log: (message) => logger.warn(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) {
logger.warn('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) {
logger.warn(
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
);
}
resolve();
},
);
});
app.focus({ steal: true });
},
withStatsWindowLayerSuspended: (showDialog) =>
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
getConfig: () => getResolvedConfig().updates,
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: getResolvedConfig().updates.notificationType, version },
{
showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
showOverlayNotification, showOverlayNotification,
showOsdNotification: (message) => { showDesktopNotification: (title, options) => showDesktopNotification(title, options),
showMpvOsd: (message) => {
showMpvOsd(message); showMpvOsd(message);
}, },
log: (message) => logger.warn(message), withStatsWindowLayerSuspendedForNativeDialog: (showDialog) =>
}, withStatsWindowLayerSuspendedForNativeDialog(showDialog),
), });
log: (message) => logger.warn(message),
});
return updateService;
}
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: { cycleSecondarySubModeMainDeps: {
@@ -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 };
}