Files
openchamber/packages/ui/src/stores/useUpdateStore.ts
T
𝖎𝖚𝖑𝖎𝖎𝖆andBohdan Triapitsyn aae889b904 perf: optimize session loading and desktop startup (#2545)
* perf: optimize session loading and startup

* fix(chat): stabilize history prepend virtualization

* perf: unblock first session open from startup network contention

Opening the first session after app start waited seconds for its message
fetch. Three independent contributors, each measured via CDP network
capture and Chromium net-log against the packaged desktop app:

- The active-session watchdog fired an uncapped per-directory status poll
  and child-session discovery burst at startup, and other subsystems
  (git checks, global session pages, command/skill discovery) fanned out
  alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin.
  Add a shared background-network gate (concurrency 3) and route the
  watchdog, poll-shaped git reads (also priority: low), global session
  pages, command/skill loads, and the background update check through it.

- The packaged renderer is cross-origin to the loopback backend, so every
  API call needs a CORS preflight; a few slow OpenCode-proxied requests
  held the whole pool while preflights and interactive traffic queued
  behind them. Lift Chromium's per-host connection cap for loopback via
  ignore-connections-limit in the Electron shell.

- OpenCode initializes each directory lazily on its first request, so the
  first click paid that cost interactively. Warm the last-used directory
  and the three most recently opened projects right after OpenCode
  readiness, sequentially and best-effort, overlapping UI startup.

Validation: new background-network tests, lifecycle warmup test, focused
store/sync tests, UI type-check and lint, dead-code report, node --check
plus electron type-check/lint, and CDP first-open measurements on the
packaged app (message fetch socket queue 5.4s -> 0.03s).

* fix(ui): keep interactive git reads out of background queue

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-07-31 12:51:15 +03:00

337 lines
11 KiB
TypeScript

import { create } from 'zustand';
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
import { getDeviceInfo } from '@/lib/device';
import { useUIStore } from './useUIStore';
import {
checkForDesktopUpdates,
downloadDesktopUpdate,
restartToApplyUpdate,
isDesktopLocalOriginActive,
isElectronShell,
isVSCodeRuntime,
isWebRuntime,
} from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
declare const __APP_VERSION__: string | undefined;
type UpdateState = {
checking: boolean;
available: boolean;
downloading: boolean;
downloaded: boolean;
info: UpdateInfo | null;
progress: UpdateProgress | null;
error: string | null;
runtimeType: 'desktop' | 'web' | 'vscode' | 'mobile' | null;
lastChecked: number | null;
nextCheckInSec: number | null;
};
interface UpdateStore extends UpdateState {
checkForUpdates: () => Promise<number | null>;
downloadUpdate: () => Promise<void>;
restartToUpdate: () => Promise<void>;
dismiss: () => void;
reset: () => void;
}
type ClientRuntime = 'desktop' | 'web' | 'vscode' | 'mobile';
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;
}
}
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' {
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';
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';
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';
}
function detectPlatform(): 'macos' | 'windows' | 'linux' | 'web' | 'android' | 'ios' {
const clientPlatform = getClientPlatform();
if (clientPlatform === 'android' || clientPlatform === 'ios') return clientPlatform;
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 {
// 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' });
params.set('deviceClass', detectDeviceClass());
params.set('arch', detectArch());
params.set('platform', detectPlatform());
if (shouldReportUsage && (runtime === 'desktop' || runtime === 'mobile')) {
const installId = getClientInstallId();
if (installId) params.set('installId', installId);
}
if (runtime === 'desktop') {
params.set('appType', 'desktop-electron');
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
return params;
}
if (runtime === 'vscode') {
params.set('appType', 'vscode');
params.set('instanceMode', 'local');
return params;
}
if (runtime === 'mobile') {
params.set('appType', 'mobile-capacitor');
params.set('instanceMode', 'remote');
return params;
}
params.set('appType', 'web');
params.set('instanceMode', 'unknown');
return params;
}
async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: string): Promise<UpdateInfo | null> {
try {
const params = mapRuntimeParams(runtime);
const vscodeVersion = typeof window !== 'undefined'
? (window as { __VSCODE_CONFIG__?: { extensionVersion?: string } }).__VSCODE_CONFIG__?.extensionVersion
: undefined;
if (currentVersion) params.set('currentVersion', currentVersion);
else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion);
const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
// Background check — keep sockets free for interactive traffic at startup.
priority: 'low',
});
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,
releaseUrl: data.releaseUrl,
downloadUrl: data.downloadUrl,
nextSuggestedCheckInSec:
typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec)
? data.nextSuggestedCheckInSec
: undefined,
packageManager: data.packageManager,
updateCommand: data.updateCommand,
};
} catch (error) {
console.warn('Failed to check for updates:', error);
return null;
}
}
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | 'mobile' | null {
if (isCapacitorApp()) {
return 'mobile';
}
if (isElectronShell()) {
return 'desktop';
}
if (isVSCodeRuntime()) return 'vscode';
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,
nextCheckInSec: null,
};
export const useUpdateStore = create<UpdateStore>()((set, get) => ({
...initialState,
checkForUpdates: async () => {
const runtime = detectRuntimeType();
if (!runtime) return null;
set({ checking: true, error: null, runtimeType: runtime });
try {
let info: UpdateInfo | null = null;
let suggestedSec: number | null = null;
if (runtime === 'desktop') {
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;
set({
checking: false,
available: desktopInfo?.available ?? false,
info: desktopInfo,
lastChecked: Date.now(),
nextCheckInSec: suggestedSec,
});
return suggestedSec;
} else if (runtime === 'web') {
info = await checkForWebUpdates('web');
suggestedSec = info?.nextSuggestedCheckInSec ?? null;
} else if (runtime === 'vscode') {
const vscodeInfo = await checkForWebUpdates('vscode');
suggestedSec = vscodeInfo?.nextSuggestedCheckInSec ?? null;
} else if (runtime === 'mobile') {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
info = await checkForWebUpdates('mobile', appVersion);
suggestedSec = info?.nextSuggestedCheckInSec ?? null;
}
set({
checking: false,
available: runtime === 'vscode' ? false : (info?.available ?? false),
info: runtime === 'vscode' ? null : info,
lastChecked: Date.now(),
nextCheckInSec: suggestedSec,
});
return suggestedSec;
} catch (error) {
set({
checking: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
});
return null;
}
},
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 {
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,
// 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,
available: state.info.available,
}
: desktopInfo,
}));
const ok = await downloadDesktopUpdate((progress) => {
set({ progress });
});
if (!ok) {
throw new Error('Desktop update only works on Local instance');
}
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 {
const ok = await restartToApplyUpdate();
if (!ok) {
throw new Error('Desktop restart only works on Local instance');
}
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to restart',
});
}
},
dismiss: () => {
set({ available: false, downloaded: false, info: null });
},
reset: () => {
set(initialState);
},
}));