mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-15 08:12:53 -07:00
feat: add auto update support
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { compareSemverLike, parseReleaseVersion } from './release-assets';
|
||||
|
||||
export interface UpdateState {
|
||||
lastAutomaticCheckAt?: number;
|
||||
lastNotifiedVersion?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
|
||||
|
||||
export interface UpdateCheckRequest {
|
||||
source: UpdateCheckSource;
|
||||
force?: boolean;
|
||||
launcherPath?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckStatus =
|
||||
| 'up-to-date'
|
||||
| 'update-available'
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'failed';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
status: UpdateCheckStatus;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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 }>;
|
||||
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
|
||||
updateLauncher: (
|
||||
launcherPath?: string,
|
||||
channel?: UpdateChannel,
|
||||
) => Promise<{ status: string; command?: string }>;
|
||||
showNoUpdateDialog: (version: string) => Promise<void>;
|
||||
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
||||
showUpdateFailedDialog: (message: string) => Promise<void>;
|
||||
downloadAppUpdate: () => Promise<void>;
|
||||
showRestartDialog: () => Promise<'restart' | 'later'>;
|
||||
quitAndInstall: () => void;
|
||||
notifyUpdateAvailable: (version: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
||||
}
|
||||
|
||||
function getBestLatestVersion(
|
||||
currentVersion: string,
|
||||
appUpdate: { available: boolean; version: string },
|
||||
release: GitHubRelease | null,
|
||||
): { available: boolean; version: string } {
|
||||
const releaseVersion = parseReleaseVersion(release);
|
||||
const candidates = [appUpdate.version, releaseVersion].filter(
|
||||
(value): value is string => typeof value === 'string' && value.length > 0,
|
||||
);
|
||||
const latest = candidates.reduce(
|
||||
(best, candidate) => (compareSemverLike(candidate, best) > 0 ? candidate : best),
|
||||
currentVersion,
|
||||
);
|
||||
return {
|
||||
available: appUpdate.available || compareSemverLike(latest, currentVersion) > 0,
|
||||
version: latest,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSkipAutomaticCheck(
|
||||
config: Required<UpdatesConfig>,
|
||||
state: UpdateState,
|
||||
now: number,
|
||||
) {
|
||||
if (!config.enabled) return true;
|
||||
if (!state.lastAutomaticCheckAt) return false;
|
||||
const intervalMs = Math.max(1, config.checkIntervalHours) * 60 * 60 * 1000;
|
||||
return now - state.lastAutomaticCheckAt < intervalMs;
|
||||
}
|
||||
|
||||
function summarizeError(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const firstLine = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
return firstLine ?? 'unknown error';
|
||||
}
|
||||
|
||||
export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
let inFlight: Promise<UpdateCheckResult> | null = null;
|
||||
|
||||
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const now = deps.now();
|
||||
const config = deps.getConfig();
|
||||
const channel = config.channel;
|
||||
const state = await deps.readState();
|
||||
const isAutomatic = request.source === 'automatic';
|
||||
|
||||
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
|
||||
return { status: 'skipped' };
|
||||
}
|
||||
|
||||
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 currentVersion = deps.getCurrentVersion();
|
||||
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
|
||||
|
||||
if (isAutomatic) {
|
||||
const nextState: UpdateState = {
|
||||
...state,
|
||||
lastAutomaticCheckAt: now,
|
||||
};
|
||||
if (latest.available && state.lastNotifiedVersion !== latest.version) {
|
||||
await deps.notifyUpdateAvailable(latest.version);
|
||||
nextState.lastNotifiedVersion = latest.version;
|
||||
}
|
||||
await deps.writeState(nextState);
|
||||
}
|
||||
|
||||
if (!latest.available) {
|
||||
if (!isAutomatic) {
|
||||
await deps.showNoUpdateDialog(currentVersion);
|
||||
}
|
||||
return { status: 'up-to-date', version: currentVersion };
|
||||
}
|
||||
|
||||
if (isAutomatic) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
const choice = await deps.showUpdateAvailableDialog(latest.version);
|
||||
if (choice === 'close') {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
||||
await deps.downloadAppUpdate();
|
||||
}
|
||||
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
|
||||
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||
}
|
||||
|
||||
const restartChoice = await deps.showRestartDialog();
|
||||
if (restartChoice === 'restart') {
|
||||
deps.quitAndInstall();
|
||||
}
|
||||
return { status: 'updated', version: latest.version };
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (isAutomatic) {
|
||||
deps.log(`Automatic update check failed: ${message}`);
|
||||
} else {
|
||||
await deps.showUpdateFailedDialog(message);
|
||||
}
|
||||
return { status: 'failed', error: message };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = runCheck(request).finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
},
|
||||
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
||||
const setTimeoutFn = deps.setTimeout ?? setTimeout;
|
||||
const setIntervalFn = deps.setInterval ?? setInterval;
|
||||
const startupDelayMs = options.startupDelayMs ?? 15_000;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 60 * 60 * 1000;
|
||||
setTimeoutFn(() => {
|
||||
void this.checkForUpdates({ source: 'automatic' });
|
||||
}, startupDelayMs);
|
||||
setIntervalFn(() => {
|
||||
void this.checkForUpdates({ source: 'automatic' });
|
||||
}, pollIntervalMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileUpdateStateStore(statePath: string): {
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
async readState(): Promise<UpdateState> {
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
async writeState(state: UpdateState): Promise<void> {
|
||||
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
|
||||
await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user