initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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();