2025-12-20 01:04:32 +02:00
|
|
|
import { create } from 'zustand';
|
|
|
|
|
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
2026-03-15 23:14:37 +02:00
|
|
|
import { getDeviceInfo } from '@/lib/device';
|
2026-03-22 23:02:53 +02:00
|
|
|
import { useUIStore } from './useUIStore';
|
2025-12-20 01:04:32 +02:00
|
|
|
import {
|
|
|
|
|
checkForDesktopUpdates,
|
|
|
|
|
downloadDesktopUpdate,
|
|
|
|
|
restartToApplyUpdate,
|
2026-02-05 01:59:49 +02:00
|
|
|
isDesktopLocalOriginActive,
|
2026-05-01 12:23:58 +03:00
|
|
|
isElectronShell,
|
2026-03-15 23:14:37 +02:00
|
|
|
isVSCodeRuntime,
|
2025-12-20 01:04:32 +02:00
|
|
|
isWebRuntime,
|
|
|
|
|
} from '@/lib/desktop';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
2026-07-07 13:39:50 +03:00
|
|
|
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
|
|
|
|
|
|
|
|
|
|
declare const __APP_VERSION__: string | undefined;
|
2025-12-20 01:04:32 +02:00
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
type UpdateState = {
|
2025-12-20 01:04:32 +02:00
|
|
|
checking: boolean;
|
|
|
|
|
available: boolean;
|
|
|
|
|
downloading: boolean;
|
|
|
|
|
downloaded: boolean;
|
|
|
|
|
info: UpdateInfo | null;
|
|
|
|
|
progress: UpdateProgress | null;
|
|
|
|
|
error: string | null;
|
2026-07-07 13:39:50 +03:00
|
|
|
runtimeType: 'desktop' | 'web' | 'vscode' | 'mobile' | null;
|
2025-12-20 01:04:32 +02:00
|
|
|
lastChecked: number | null;
|
2026-03-15 23:14:37 +02:00
|
|
|
nextCheckInSec: number | null;
|
2025-12-20 01:04:32 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface UpdateStore extends UpdateState {
|
2026-03-15 23:14:37 +02:00
|
|
|
checkForUpdates: () => Promise<number | null>;
|
2025-12-20 01:04:32 +02:00
|
|
|
downloadUpdate: () => Promise<void>;
|
|
|
|
|
restartToUpdate: () => Promise<void>;
|
|
|
|
|
dismiss: () => void;
|
|
|
|
|
reset: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 13:39:50 +03:00
|
|
|
type ClientRuntime = 'desktop' | 'web' | 'vscode' | 'mobile';
|
2026-03-15 23:14:37 +02:00
|
|
|
|
2026-07-15 14:02:12 +03:00
|
|
|
const CLIENT_INSTALL_ID_KEY = 'openchamber.update-install-id';
|
|
|
|
|
|
|
|
|
|
function getClientInstallId(): string | undefined {
|
|
|
|
|
if (typeof window === 'undefined' || typeof crypto.randomUUID !== 'function') return undefined;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = window.localStorage.getItem(CLIENT_INSTALL_ID_KEY)?.trim();
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
|
|
|
|
|
const installId = crypto.randomUUID();
|
|
|
|
|
window.localStorage.setItem(CLIENT_INSTALL_ID_KEY, installId);
|
|
|
|
|
return installId;
|
|
|
|
|
} catch {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 23:14:37 +02:00
|
|
|
function detectDeviceClass(): 'mobile' | 'tablet' | 'desktop' | 'unknown' {
|
|
|
|
|
if (typeof window === 'undefined') return 'unknown';
|
|
|
|
|
try {
|
|
|
|
|
const { deviceType } = getDeviceInfo();
|
|
|
|
|
return deviceType;
|
|
|
|
|
} catch {
|
|
|
|
|
return 'unknown';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detectArch(): 'arm64' | 'x64' | 'unknown' {
|
2026-07-28 14:56:15 +03:00
|
|
|
const electronArch = typeof window !== 'undefined'
|
|
|
|
|
? window.__OPENCHAMBER_ELECTRON__?.arch?.toLowerCase?.()
|
|
|
|
|
: undefined;
|
|
|
|
|
if (electronArch === 'arm64' || electronArch === 'aarch64') return 'arm64';
|
|
|
|
|
if (electronArch === 'x64' || electronArch === 'amd64' || electronArch === 'x86_64') return 'x64';
|
|
|
|
|
|
2026-05-01 12:23:58 +03:00
|
|
|
const vscodeArch = typeof window !== 'undefined'
|
|
|
|
|
? (window as { __VSCODE_CONFIG__?: { arch?: string } }).__VSCODE_CONFIG__?.arch?.toLowerCase?.()
|
|
|
|
|
: undefined;
|
|
|
|
|
if (vscodeArch === 'arm64' || vscodeArch === 'aarch64') return 'arm64';
|
|
|
|
|
if (vscodeArch === 'x64' || vscodeArch === 'amd64' || vscodeArch === 'x86_64') return 'x64';
|
|
|
|
|
|
2026-03-15 23:14:37 +02:00
|
|
|
const nav = typeof navigator !== 'undefined' ? (navigator as Navigator & { userAgentData?: { architecture?: string } }).userAgentData : undefined;
|
|
|
|
|
const fromUAData = nav?.architecture?.toLowerCase?.();
|
|
|
|
|
if (fromUAData === 'arm' || fromUAData === 'arm64' || fromUAData === 'aarch64') return 'arm64';
|
|
|
|
|
if (fromUAData === 'x86' || fromUAData === 'x64' || fromUAData === 'amd64') return 'x64';
|
|
|
|
|
|
|
|
|
|
const ua = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
|
|
|
|
|
if (ua.includes('aarch64') || ua.includes('arm64') || ua.includes('armv')) return 'arm64';
|
|
|
|
|
if (ua.includes('x86_64') || ua.includes('x64') || ua.includes('amd64') || ua.includes('win64')) return 'x64';
|
|
|
|
|
return 'unknown';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 13:39:50 +03:00
|
|
|
function detectPlatform(): 'macos' | 'windows' | 'linux' | 'web' | 'android' | 'ios' {
|
|
|
|
|
const clientPlatform = getClientPlatform();
|
|
|
|
|
if (clientPlatform === 'android' || clientPlatform === 'ios') return clientPlatform;
|
2026-03-15 23:14:37 +02:00
|
|
|
if (typeof navigator === 'undefined') return 'web';
|
|
|
|
|
const platform = (navigator.platform || '').toLowerCase();
|
|
|
|
|
if (platform.includes('mac')) return 'macos';
|
|
|
|
|
if (platform.includes('win')) return 'windows';
|
|
|
|
|
if (platform.includes('linux')) return 'linux';
|
|
|
|
|
return 'web';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
|
2026-03-22 23:02:53 +02:00
|
|
|
// Check if user has opted out of usage reporting (default: true/enabled from UI store)
|
|
|
|
|
const shouldReportUsage = useUIStore.getState().reportUsage;
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams({ reportUsage: shouldReportUsage ? 'true' : 'false' });
|
2026-03-15 23:14:37 +02:00
|
|
|
params.set('deviceClass', detectDeviceClass());
|
|
|
|
|
params.set('arch', detectArch());
|
|
|
|
|
params.set('platform', detectPlatform());
|
2026-07-15 14:02:12 +03:00
|
|
|
if (shouldReportUsage && (runtime === 'desktop' || runtime === 'mobile')) {
|
|
|
|
|
const installId = getClientInstallId();
|
|
|
|
|
if (installId) params.set('installId', installId);
|
|
|
|
|
}
|
2026-03-15 23:14:37 +02:00
|
|
|
if (runtime === 'desktop') {
|
2026-06-03 02:42:00 +03:00
|
|
|
params.set('appType', 'desktop-electron');
|
2026-03-15 23:14:37 +02:00
|
|
|
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
|
|
|
|
|
return params;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (runtime === 'vscode') {
|
|
|
|
|
params.set('appType', 'vscode');
|
|
|
|
|
params.set('instanceMode', 'local');
|
|
|
|
|
return params;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 13:39:50 +03:00
|
|
|
if (runtime === 'mobile') {
|
|
|
|
|
params.set('appType', 'mobile-capacitor');
|
|
|
|
|
params.set('instanceMode', 'remote');
|
|
|
|
|
return params;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 23:14:37 +02:00
|
|
|
params.set('appType', 'web');
|
|
|
|
|
params.set('instanceMode', 'unknown');
|
|
|
|
|
return params;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: string): Promise<UpdateInfo | null> {
|
2025-12-20 01:04:32 +02:00
|
|
|
try {
|
2026-03-15 23:14:37 +02:00
|
|
|
const params = mapRuntimeParams(runtime);
|
2026-05-01 12:23:58 +03:00
|
|
|
const vscodeVersion = typeof window !== 'undefined'
|
|
|
|
|
? (window as { __VSCODE_CONFIG__?: { extensionVersion?: string } }).__VSCODE_CONFIG__?.extensionVersion
|
|
|
|
|
: undefined;
|
2026-03-15 23:14:37 +02:00
|
|
|
if (currentVersion) params.set('currentVersion', currentVersion);
|
2026-05-01 12:23:58 +03:00
|
|
|
else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion);
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
|
2025-12-20 01:04:32 +02:00
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json' },
|
2026-07-31 12:51:15 +03:00
|
|
|
// Background check — keep sockets free for interactive traffic at startup.
|
|
|
|
|
priority: 'low',
|
2025-12-20 01:04:32 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Server responded with ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
return {
|
|
|
|
|
available: data.available ?? false,
|
|
|
|
|
version: data.version,
|
|
|
|
|
currentVersion: data.currentVersion ?? 'unknown',
|
|
|
|
|
body: data.body,
|
2026-07-07 13:39:50 +03:00
|
|
|
releaseUrl: data.releaseUrl,
|
|
|
|
|
downloadUrl: data.downloadUrl,
|
2026-03-15 23:14:37 +02:00
|
|
|
nextSuggestedCheckInSec:
|
|
|
|
|
typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec)
|
|
|
|
|
? data.nextSuggestedCheckInSec
|
|
|
|
|
: undefined,
|
2025-12-20 01:04:32 +02:00
|
|
|
packageManager: data.packageManager,
|
|
|
|
|
updateCommand: data.updateCommand,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
2026-03-15 23:14:37 +02:00
|
|
|
console.warn('Failed to check for updates:', error);
|
2025-12-20 01:04:32 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 13:39:50 +03:00
|
|
|
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | 'mobile' | null {
|
|
|
|
|
if (isCapacitorApp()) {
|
|
|
|
|
return 'mobile';
|
|
|
|
|
}
|
2026-06-03 02:42:00 +03:00
|
|
|
if (isElectronShell()) {
|
2026-06-02 00:43:05 +03:00
|
|
|
return 'desktop';
|
2026-02-05 01:59:49 +02:00
|
|
|
}
|
2026-03-15 23:14:37 +02:00
|
|
|
if (isVSCodeRuntime()) return 'vscode';
|
2025-12-20 01:04:32 +02:00
|
|
|
if (isWebRuntime()) return 'web';
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initialState: UpdateState = {
|
|
|
|
|
checking: false,
|
|
|
|
|
available: false,
|
|
|
|
|
downloading: false,
|
|
|
|
|
downloaded: false,
|
|
|
|
|
info: null,
|
|
|
|
|
progress: null,
|
|
|
|
|
error: null,
|
|
|
|
|
runtimeType: null,
|
|
|
|
|
lastChecked: null,
|
2026-03-15 23:14:37 +02:00
|
|
|
nextCheckInSec: null,
|
2025-12-20 01:04:32 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const useUpdateStore = create<UpdateStore>()((set, get) => ({
|
|
|
|
|
...initialState,
|
|
|
|
|
|
|
|
|
|
checkForUpdates: async () => {
|
|
|
|
|
const runtime = detectRuntimeType();
|
2026-03-15 23:14:37 +02:00
|
|
|
if (!runtime) return null;
|
2025-12-20 01:04:32 +02:00
|
|
|
|
|
|
|
|
set({ checking: true, error: null, runtimeType: runtime });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
let info: UpdateInfo | null = null;
|
2026-03-15 23:14:37 +02:00
|
|
|
let suggestedSec: number | null = null;
|
2025-12-20 01:04:32 +02:00
|
|
|
|
|
|
|
|
if (runtime === 'desktop') {
|
2026-07-15 14:02:12 +03:00
|
|
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
|
|
|
|
|
const [desktopResult, apiResult] = await Promise.allSettled([
|
|
|
|
|
checkForDesktopUpdates(),
|
|
|
|
|
checkForWebUpdates('desktop', appVersion),
|
|
|
|
|
]);
|
|
|
|
|
const desktopInfo = desktopResult.status === 'fulfilled' ? desktopResult.value : null;
|
|
|
|
|
suggestedSec = apiResult.status === 'fulfilled'
|
|
|
|
|
? (apiResult.value?.nextSuggestedCheckInSec ?? null)
|
|
|
|
|
: null;
|
2026-03-20 02:04:33 +02:00
|
|
|
set({
|
|
|
|
|
checking: false,
|
|
|
|
|
available: desktopInfo?.available ?? false,
|
|
|
|
|
info: desktopInfo,
|
|
|
|
|
lastChecked: Date.now(),
|
2026-07-15 14:02:12 +03:00
|
|
|
nextCheckInSec: suggestedSec,
|
2026-03-20 02:04:33 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return suggestedSec;
|
2025-12-20 01:04:32 +02:00
|
|
|
} else if (runtime === 'web') {
|
2026-03-15 23:14:37 +02:00
|
|
|
info = await checkForWebUpdates('web');
|
|
|
|
|
suggestedSec = info?.nextSuggestedCheckInSec ?? null;
|
|
|
|
|
} else if (runtime === 'vscode') {
|
|
|
|
|
const vscodeInfo = await checkForWebUpdates('vscode');
|
|
|
|
|
suggestedSec = vscodeInfo?.nextSuggestedCheckInSec ?? null;
|
2026-07-07 13:39:50 +03:00
|
|
|
} else if (runtime === 'mobile') {
|
|
|
|
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
|
|
|
|
|
info = await checkForWebUpdates('mobile', appVersion);
|
|
|
|
|
suggestedSec = info?.nextSuggestedCheckInSec ?? null;
|
2025-12-20 01:04:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set({
|
|
|
|
|
checking: false,
|
2026-03-15 23:14:37 +02:00
|
|
|
available: runtime === 'vscode' ? false : (info?.available ?? false),
|
|
|
|
|
info: runtime === 'vscode' ? null : info,
|
2025-12-20 01:04:32 +02:00
|
|
|
lastChecked: Date.now(),
|
2026-03-15 23:14:37 +02:00
|
|
|
nextCheckInSec: suggestedSec,
|
2025-12-20 01:04:32 +02:00
|
|
|
});
|
2026-03-15 23:14:37 +02:00
|
|
|
return suggestedSec;
|
2025-12-20 01:04:32 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
set({
|
|
|
|
|
checking: false,
|
|
|
|
|
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
|
|
|
|
});
|
2026-03-15 23:14:37 +02:00
|
|
|
return null;
|
2025-12-20 01:04:32 +02:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
downloadUpdate: async () => {
|
|
|
|
|
const { available, runtimeType } = get();
|
|
|
|
|
|
|
|
|
|
// For web runtime, there's no download - user uses in-app update or CLI
|
|
|
|
|
if (runtimeType !== 'desktop' || !available) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set({ downloading: true, error: null, progress: null });
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-20 02:04:33 +02:00
|
|
|
const desktopInfo = await checkForDesktopUpdates();
|
|
|
|
|
if (!desktopInfo?.available) {
|
|
|
|
|
throw new Error('Update detected, but desktop package is not ready yet. Retry in a moment.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
info: state.info
|
|
|
|
|
? {
|
|
|
|
|
...state.info,
|
|
|
|
|
...desktopInfo,
|
2026-04-22 00:57:58 +03:00
|
|
|
// Keep the richer sidecar-sourced changelog; desktopInfo.body is
|
|
|
|
|
// often the bare "See release notes at..." fallback from the
|
|
|
|
|
// updater and would otherwise clobber the nice changelog.
|
|
|
|
|
body: state.info.body || desktopInfo.body,
|
2026-03-20 02:04:33 +02:00
|
|
|
available: state.info.available,
|
|
|
|
|
}
|
|
|
|
|
: desktopInfo,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
const ok = await downloadDesktopUpdate((progress) => {
|
2025-12-20 01:04:32 +02:00
|
|
|
set({ progress });
|
|
|
|
|
});
|
2026-02-05 01:59:49 +02:00
|
|
|
if (!ok) {
|
|
|
|
|
throw new Error('Desktop update only works on Local instance');
|
|
|
|
|
}
|
2025-12-20 01:04:32 +02:00
|
|
|
set({ downloading: false, downloaded: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
set({
|
|
|
|
|
downloading: false,
|
|
|
|
|
error: error instanceof Error ? error.message : 'Failed to download update',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
restartToUpdate: async () => {
|
|
|
|
|
const { downloaded, runtimeType } = get();
|
|
|
|
|
|
|
|
|
|
if (runtimeType !== 'desktop' || !downloaded) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-05 01:59:49 +02:00
|
|
|
const ok = await restartToApplyUpdate();
|
|
|
|
|
if (!ok) {
|
|
|
|
|
throw new Error('Desktop restart only works on Local instance');
|
|
|
|
|
}
|
2025-12-20 01:04:32 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
set({
|
|
|
|
|
error: error instanceof Error ? error.message : 'Failed to restart',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
dismiss: () => {
|
|
|
|
|
set({ available: false, downloaded: false, info: null });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
reset: () => {
|
|
|
|
|
set(initialState);
|
|
|
|
|
},
|
|
|
|
|
}));
|