import { createSignal } from 'solid-js'; import { UpdatesAPI } from '@/api/updates'; import type { UpdateInfo, VersionInfo } from '@/api/updates'; import { getPulseHostname } from '@/utils/url'; import { logger } from '@/utils/logger'; import { STORAGE_KEYS } from '@/utils/localStorage'; const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours interface UpdateState { lastCheck: number; dismissedVersion?: string; updateInfo?: UpdateInfo; } // Load state from localStorage const loadState = (): UpdateState => { try { const stored = localStorage.getItem(STORAGE_KEYS.UPDATES); if (stored) { return JSON.parse(stored); } } catch (e) { logger.error('Failed to load update state:', e); } return { lastCheck: 0 }; }; // Save state to localStorage const saveState = (state: UpdateState) => { try { localStorage.setItem(STORAGE_KEYS.UPDATES, JSON.stringify(state)); } catch (e) { logger.error('Failed to save update state:', e); } }; // Create signals const [updateAvailable, setUpdateAvailable] = createSignal(false); const [updateInfo, setUpdateInfo] = createSignal(null); const [versionInfo, setVersionInfo] = createSignal(null); const [isChecking, setIsChecking] = createSignal(false); const [isDismissed, setIsDismissed] = createSignal(false); const [lastError, setLastError] = createSignal(null); // Check for updates const checkForUpdates = async (force = false): Promise => { // Don't check if already checking if (isChecking()) return; const state = loadState(); const now = Date.now(); // Skip if checked recently (unless forced) if (!force && state.lastCheck && now - state.lastCheck < CHECK_INTERVAL) { // Use cached data if available if (state.updateInfo) { // First check if version matches (in case user updated) try { const currentVersion = await UpdatesAPI.getVersion(); if (state.updateInfo.currentVersion !== currentVersion.version) { // Version changed, invalidate cache and check again state.updateInfo = undefined; state.dismissedVersion = undefined; state.lastCheck = 0; saveState(state); // Continue to check for updates } else { // Version matches, use cached data setUpdateInfo(state.updateInfo); setUpdateAvailable(state.updateInfo.available); // Check if this version was dismissed if (state.dismissedVersion === state.updateInfo.latestVersion) { setIsDismissed(true); } return; } } catch (e) { // If we can't get version, continue with normal check logger.error('Failed to verify version for cache:', e); } } else { return; } } setIsChecking(true); setLastError(null); try { // First get version info to check deployment type const version = await UpdatesAPI.getVersion(); setVersionInfo(version); // Clear cache if version has changed (user updated) if (state.updateInfo && state.updateInfo.currentVersion !== version.version) { // Version changed, clear the cache state.updateInfo = undefined; state.dismissedVersion = undefined; saveState(state); } // Don't check for updates in Docker or development builds if (version.isDocker || version.isDevelopment || version.isSourceBuild) { setUpdateAvailable(false); setUpdateInfo(null); return; } // Skip dev builds unless forcing (contains -dirty or commit hash after version) const isDirtyBuild = version.version.includes('-dirty') || /v\d+\.\d+\.\d+.*-g[0-9a-f]+/.test(version.version); if (isDirtyBuild && !force) { setUpdateAvailable(false); return; } // Get the saved update channel from system settings const info = await UpdatesAPI.checkForUpdates(); setUpdateInfo(info); setUpdateAvailable(info.available); // Check if this version was dismissed if (state.dismissedVersion === info.latestVersion) { setIsDismissed(true); } else { setIsDismissed(false); } // Save to cache saveState({ ...state, lastCheck: now, updateInfo: info, }); } catch (error) { logger.error('Failed to check for updates:', error); setLastError(error instanceof Error ? error.message : 'Failed to check for updates'); setUpdateAvailable(false); } finally { setIsChecking(false); } }; // Dismiss current update const dismissUpdate = () => { const info = updateInfo(); if (!info) return; const state = loadState(); saveState({ ...state, dismissedVersion: info.latestVersion, }); setIsDismissed(true); }; // Clear dismissed version (useful when user wants to see the update again) const clearDismissed = () => { const state = loadState(); delete state.dismissedVersion; saveState(state); setIsDismissed(false); }; // Check if update is visible (available and not dismissed) const isUpdateVisible = () => updateAvailable() && !isDismissed(); // Export store export const updateStore = { // State updateAvailable, updateInfo, versionInfo, isChecking, isDismissed, lastError, isUpdateVisible, // Actions checkForUpdates, dismissUpdate, clearDismissed, // Manual testing helpers simulateUpdate: (version: string = 'v5.0.0') => { setUpdateInfo({ available: true, currentVersion: versionInfo()?.version || 'v4.9.0', latestVersion: version, releaseNotes: 'Test update notification', releaseDate: new Date().toISOString(), downloadUrl: '#', isPrerelease: false, }); setUpdateAvailable(true); setIsDismissed(false); }, }; // Expose for testing in development declare global { interface Window { updateStore?: typeof updateStore; } } const pulseHostname = getPulseHostname(); if ( import.meta.env.DEV || pulseHostname === 'localhost' || pulseHostname.startsWith('192.168') ) { window.updateStore = updateStore; }