initial commit
This commit is contained in:
378
fluxer_app/src/stores/UpdaterStore.tsx
Normal file
378
fluxer_app/src/stores/UpdaterStore.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {makeAutoObservable, runInAction} from 'mobx';
|
||||
import Config from '~/Config';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {getClientInfo} from '~/utils/ClientInfoUtils';
|
||||
import {getElectronAPI, isElectron} from '~/utils/NativeUtils';
|
||||
import type {UpdaterEvent} from '../../src-electron/common/types';
|
||||
|
||||
const logger = new Logger('UpdaterStore');
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const VERSION_ENDPOINT = '/version.json';
|
||||
const CURRENT_BUILD_SHA = Config.PUBLIC_BUILD_SHA ?? null;
|
||||
|
||||
export type UpdaterState = 'idle' | 'checking' | 'available';
|
||||
|
||||
export type UpdateType = 'native' | 'web' | 'both' | null;
|
||||
|
||||
export interface NativeUpdateInfo {
|
||||
available: boolean;
|
||||
downloaded: boolean;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface WebUpdateInfo {
|
||||
available: boolean;
|
||||
sha: string | null;
|
||||
buildNumber: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
native: NativeUpdateInfo;
|
||||
web: WebUpdateInfo;
|
||||
}
|
||||
|
||||
class UpdaterStoreImpl {
|
||||
updateType: UpdateType = null;
|
||||
updateInfo: UpdateInfo = {
|
||||
native: {available: false, downloaded: false, version: null},
|
||||
web: {available: false, sha: null, buildNumber: null},
|
||||
};
|
||||
lastCheckedAt: number | null = null;
|
||||
currentVersion: string | null = null;
|
||||
channel: string | null = null;
|
||||
|
||||
private _isChecking = false;
|
||||
|
||||
private isNative: boolean;
|
||||
private backgroundCheckStarted = false;
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private checkInProgress = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
|
||||
this.isNative = isElectron();
|
||||
void this.bootstrap();
|
||||
}
|
||||
|
||||
get hasUpdate(): boolean {
|
||||
return this.updateInfo.native.available || this.updateInfo.web.available;
|
||||
}
|
||||
|
||||
get nativeUpdatePending(): boolean {
|
||||
return this.updateInfo.native.available && !this.updateInfo.native.downloaded;
|
||||
}
|
||||
|
||||
get nativeUpdateReady(): boolean {
|
||||
return this.updateInfo.native.available && this.updateInfo.native.downloaded;
|
||||
}
|
||||
|
||||
get state(): UpdaterState {
|
||||
if (this._isChecking) return 'checking';
|
||||
if (this.hasUpdate) return 'available';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
get isChecking(): boolean {
|
||||
return this._isChecking;
|
||||
}
|
||||
|
||||
get displayVersion(): string | null {
|
||||
if (this.updateInfo.native.available && this.updateInfo.native.version) {
|
||||
return this.updateInfo.native.version;
|
||||
}
|
||||
if (this.updateInfo.web.available) {
|
||||
if (this.updateInfo.web.buildNumber) {
|
||||
return `Build ${this.updateInfo.web.buildNumber}`;
|
||||
}
|
||||
return this.updateInfo.web.sha?.slice(0, 7) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private refreshUpdateType(): void {
|
||||
const nativeReady = this.nativeUpdateReady;
|
||||
const hasWeb = this.updateInfo.web.available;
|
||||
|
||||
if (nativeReady && hasWeb) {
|
||||
this.updateType = 'both';
|
||||
} else if (nativeReady) {
|
||||
this.updateType = 'native';
|
||||
} else if (hasWeb) {
|
||||
this.updateType = 'web';
|
||||
} else {
|
||||
this.updateType = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrap(): Promise<void> {
|
||||
if (this.isNative) {
|
||||
await this.bootstrapNative();
|
||||
}
|
||||
this.startBackgroundChecks();
|
||||
void this.checkForUpdates(false);
|
||||
}
|
||||
|
||||
private async bootstrapNative(): Promise<void> {
|
||||
try {
|
||||
const info = await getClientInfo();
|
||||
runInAction(() => {
|
||||
this.currentVersion = info.desktopVersion ?? null;
|
||||
this.channel = info.desktopChannel ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read desktop info', error);
|
||||
}
|
||||
|
||||
this.subscribeToNativeEvents();
|
||||
}
|
||||
|
||||
private subscribeToNativeEvents(): void {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
|
||||
this.unsubscribeNativeEvents = electronApi.onUpdaterEvent((event: UpdaterEvent) => {
|
||||
this.handleNativeEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
private handleNativeEvent(event: UpdaterEvent): void {
|
||||
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
|
||||
|
||||
switch (event.type) {
|
||||
case 'checking':
|
||||
runInAction(() => {
|
||||
this._isChecking = true;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'available':
|
||||
runInAction(() => {
|
||||
this.updateInfo.native = {
|
||||
available: true,
|
||||
downloaded: false,
|
||||
version: event.version ?? null,
|
||||
};
|
||||
this.refreshUpdateType();
|
||||
this._isChecking = false;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'not-available': {
|
||||
const hadDownloaded = this.updateInfo.native.downloaded;
|
||||
runInAction(() => {
|
||||
this.lastCheckedAt = Date.now();
|
||||
|
||||
if (!hadDownloaded) {
|
||||
this.updateInfo.native = {
|
||||
available: false,
|
||||
downloaded: false,
|
||||
version: null,
|
||||
};
|
||||
|
||||
this.refreshUpdateType();
|
||||
}
|
||||
|
||||
this._isChecking = false;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
if (isBackgroundOrFocusCheck) {
|
||||
logger.debug('Background update check failed silently:', event.message);
|
||||
} else {
|
||||
logger.warn('Update check error:', event.message);
|
||||
}
|
||||
runInAction(() => {
|
||||
this._isChecking = false;
|
||||
this.checkInProgress = false;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'downloaded':
|
||||
runInAction(() => {
|
||||
this.updateInfo.native = {
|
||||
available: true,
|
||||
downloaded: true,
|
||||
version: event.version ?? null,
|
||||
};
|
||||
this.refreshUpdateType();
|
||||
this._isChecking = false;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private startBackgroundChecks(): void {
|
||||
if (this.backgroundCheckStarted) return;
|
||||
this.backgroundCheckStarted = true;
|
||||
|
||||
window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void this.checkForUpdates(false);
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
window.addEventListener('focus', () => void this.checkForUpdates(false));
|
||||
window.addEventListener('online', () => void this.checkForUpdates(true));
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void this.checkForUpdates(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private shouldThrottle(force: boolean): boolean {
|
||||
if (force) return false;
|
||||
if (this.lastCheckedAt == null) return false;
|
||||
return Date.now() - this.lastCheckedAt < MIN_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
private shouldRunNativeCheck(): boolean {
|
||||
return this.isNative && !this.updateInfo.native.available;
|
||||
}
|
||||
|
||||
async checkForUpdates(force = false): Promise<void> {
|
||||
if (this.checkInProgress) return;
|
||||
if (this.shouldThrottle(force)) return;
|
||||
|
||||
this.checkInProgress = true;
|
||||
|
||||
runInAction(() => {
|
||||
this._isChecking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
const shouldCheckNative = this.shouldRunNativeCheck();
|
||||
const [, webResult] = await Promise.all([
|
||||
shouldCheckNative ? this.checkNativeUpdate(force ? 'user' : 'background') : Promise.resolve(null),
|
||||
this.checkWebUpdate(),
|
||||
]);
|
||||
|
||||
runInAction(() => {
|
||||
this.lastCheckedAt = Date.now();
|
||||
|
||||
this.updateInfo.web = {
|
||||
available: webResult?.available ?? false,
|
||||
sha: webResult?.sha ?? null,
|
||||
buildNumber: webResult?.buildNumber ?? null,
|
||||
};
|
||||
|
||||
this.refreshUpdateType();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug('Update check failed silently:', err);
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
this.lastCheckedAt = Date.now();
|
||||
this.checkInProgress = false;
|
||||
this._isChecking = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async checkNativeUpdate(context: 'user' | 'background'): Promise<boolean> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return false;
|
||||
|
||||
try {
|
||||
await electronApi.updaterCheck(context);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug('Native update check failed silently:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkWebUpdate(): Promise<{available: boolean; sha: string | null; buildNumber: number | null}> {
|
||||
try {
|
||||
const response = await fetch(VERSION_ENDPOINT, {
|
||||
cache: 'no-store',
|
||||
headers: {'Cache-Control': 'no-cache'},
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.debug('Version endpoint not available');
|
||||
return {available: false, sha: null, buildNumber: null};
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {sha?: string; buildNumber?: number; buildTimestamp?: string};
|
||||
const updateAvailable = Boolean(payload.sha && CURRENT_BUILD_SHA && payload.sha !== CURRENT_BUILD_SHA);
|
||||
|
||||
return {
|
||||
available: updateAvailable,
|
||||
sha: payload.sha ?? null,
|
||||
buildNumber: payload.buildNumber ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug('Failed to fetch version info silently:', error);
|
||||
return {available: false, sha: null, buildNumber: null};
|
||||
}
|
||||
}
|
||||
|
||||
async applyUpdate(): Promise<void> {
|
||||
if (!this.hasUpdate) return;
|
||||
|
||||
if (this.updateType === 'web') {
|
||||
logger.info('Applying web update, reloading...');
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isNative && (this.updateType === 'native' || this.updateType === 'both')) {
|
||||
const electronApi = getElectronAPI();
|
||||
if (electronApi && this.updateInfo.native.downloaded) {
|
||||
logger.info('Installing downloaded native update...');
|
||||
await electronApi.updaterInstall();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
runInAction(() => {
|
||||
this.updateType = null;
|
||||
this.updateInfo = {
|
||||
native: {available: false, downloaded: false, version: null},
|
||||
web: {available: false, sha: null, buildNumber: null},
|
||||
};
|
||||
this.lastCheckedAt = null;
|
||||
this._isChecking = false;
|
||||
this.checkInProgress = false;
|
||||
this.refreshUpdateType();
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.unsubscribeNativeEvents) {
|
||||
this.unsubscribeNativeEvents();
|
||||
this.unsubscribeNativeEvents = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new UpdaterStoreImpl();
|
||||
Reference in New Issue
Block a user