Fixes from real fresh-install feedback (Framework node .81) + its log bundle:
Backend:
- websocket: subscribe before initial snapshot — broadcasts in the gap were
silently lost, stranding clients on stale state until a hard refresh
(the "everything needs ctrl-r" bug: My Apps stuck Loading, App Store
stuck Checking, containers-scanned never arriving)
- crash recovery: check the crash marker BEFORE writing our own PID —
recovery had never run on any node (always saw its own PID and skipped);
PID-reuse guard via /proc cmdline
- boot status: pending-boot-starts registry (recovery, stack recovery,
reconciler, adoption) — scanner overlays queued-but-down apps as
Restarting instead of Stopped after a reboot; scanner-authored
Restarting resolves immediately on a settled scan (no transitional wedge)
- install deps: bounded wait (36x5s) when a dependency is installed but
still starting ("Waiting for Bitcoin to start…") instead of instant
rejection; dependency-gate rejections remove the optimistic entry (no
phantom Stopped tile) and surface as a notification
- seed backup: auth.setup persists the onboarding mnemonic as the
encrypted seed backup (reveal previously failed on EVERY node — nothing
ever wrote master_seed.enc); seed.restore stashes too; error sanitizer
lets seed/2FA errors through instead of "Check server logs"
- lnd: bitcoind.rpchost resolved from the running Bitcoin variant
(hardcoded bitcoin-knots broke Core nodes); manifest uses derived_env
- bitcoin status: clean human message for connection-reset/startup; raw
URLs + os-error chains no longer reach the app card
- fedimint-clientd: chown /var/lib/archipelago/fmcd to 1000:1000 (root-
created dir crash-looped the rootless container, EACCES) — first-boot
script + pre-start self-heal
- log volume (>1GB/day on a day-old node): journald caps drop-in (ISO +
bootstrap self-heal), bitcoind -printtoconsole=0 everywhere (90% of the
journal was IBD UpdateTip spam), tracing default debug→info
Frontend:
- Login: Enter advances to confirm field then submits; submit always
clickable with inline errors (was silently disabled on mismatch);
Restart Onboarding needs a confirming second click (the mismatch →
"onboarding restarted" trap)
- sync store: 30s state reconciliation + refetch on re-entrant connect;
20s containers-scanned escape hatch so Checking can never show forever;
fresh empty node reaches the real "no apps yet" state
- intro video: CRF20 re-encode (SSIM 0.988) + faststart — moov was at EOF
so playback needed the full 15MB first (the intro lag)
- backgrounds: 10 heaviest JPEGs → WebP q90 (9.4MB→6.6MB); 7 stayed JPEG
(WebP larger on noisy sources)
- Web5ConnectedNodes: drop unused template ref that failed vue-tsc -b
ISO/kiosk:
- nginx: /assets/ 404s no longer cached immutable for a year; HTTPS block
gained the missing /assets/ location (served index.html as images)
- kiosk: launcher/service spliced from configs/ at ISO build (stale
heredoc force-disabled GPU); MemoryHigh/Max 1200/1500→2200/2800M (kiosk
rode the reclaim throttle = the lag); firmware-intel-graphics +
firmware-amd-graphics (trixie split DMC blobs out of misc-nonfree)
Verified: cargo test 898/898 green, npm run build green with dist
contents confirmed (webp refs, lnd.png, faststart video, new strings).
Handover for ISO build + deploy: docs/HANDOVER-2026-07-02-iso-feedback.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
229 lines
7.4 KiB
TypeScript
229 lines
7.4 KiB
TypeScript
// Sync store — WebSocket connection, real-time data, patch application
|
|
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import type { DataModel } from '../types/api'
|
|
import { wsClient, applyDataPatch } from '../api/websocket'
|
|
import { rpcClient } from '../api/rpc-client'
|
|
|
|
export const useSyncStore = defineStore('sync', () => {
|
|
// State
|
|
const data = ref<DataModel | null>(null)
|
|
const isConnected = ref(false)
|
|
const isReconnecting = ref(false)
|
|
const hasLoadedInitialData = ref(false)
|
|
let isWsSubscribed = false
|
|
let isWsConnecting = false
|
|
let isRefreshingState = false
|
|
let stalenessTimer: ReturnType<typeof setInterval> | null = null
|
|
const STALENESS_INTERVAL_MS = 30_000
|
|
|
|
// Computed
|
|
const serverInfo = computed(() => data.value?.['server-info'])
|
|
const packages = computed(() => data.value?.['package-data'] || {})
|
|
const peerHealth = computed<Record<string, boolean>>(() => data.value?.['peer-health'] || {})
|
|
const uiData = computed(() => data.value?.ui)
|
|
|
|
/**
|
|
* Refetch the full state snapshot via RPC and apply it through the same
|
|
* path as the post-connect fetch (revision/hasLoadedInitialData handling
|
|
* stays identical). Guarded against overlapping fetches.
|
|
*/
|
|
async function refreshStateFromServer(): Promise<void> {
|
|
if (isRefreshingState) return
|
|
isRefreshingState = true
|
|
try {
|
|
const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' })
|
|
if (freshState?.data) {
|
|
data.value = freshState.data
|
|
hasLoadedInitialData.value = true
|
|
}
|
|
} catch {
|
|
// Non-fatal: WebSocket patches will still work
|
|
if (import.meta.env.DEV) console.warn('[Store] Failed to refresh state from server')
|
|
} finally {
|
|
isRefreshingState = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Belt-and-suspenders against missed broadcasts: while the WebSocket is
|
|
* connected, periodically resync the full state so a dropped patch can
|
|
* never permanently strand the UI on stale data.
|
|
*/
|
|
function startStalenessReconciliation(): void {
|
|
if (stalenessTimer) return
|
|
stalenessTimer = setInterval(() => {
|
|
if (wsClient.isConnected()) void refreshStateFromServer()
|
|
}, STALENESS_INTERVAL_MS)
|
|
}
|
|
|
|
function stopStalenessReconciliation(): void {
|
|
if (stalenessTimer) {
|
|
clearInterval(stalenessTimer)
|
|
stalenessTimer = null
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
async function connectWebSocket(): Promise<void> {
|
|
// Prevent concurrent connection attempts
|
|
if (isWsConnecting) return
|
|
isWsConnecting = true
|
|
|
|
try {
|
|
if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...')
|
|
isReconnecting.value = true
|
|
|
|
// Don't create multiple subscriptions - check if already subscribed
|
|
if (!isWsSubscribed) {
|
|
// Subscribe to updates BEFORE connecting (so we catch initial data)
|
|
isWsSubscribed = true
|
|
|
|
// Listen for connection state changes
|
|
wsClient.onConnectionStateChange((state) => {
|
|
if (import.meta.env.DEV) console.log('[Store] WebSocket connection state changed:', state)
|
|
isConnected.value = state === 'connected'
|
|
isReconnecting.value = state === 'connecting'
|
|
})
|
|
|
|
wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => {
|
|
// Handle mock backend format: {type: 'initial', data: {...}}
|
|
if (update?.type === 'initial' && update?.data) {
|
|
if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend')
|
|
data.value = update.data
|
|
hasLoadedInitialData.value = true
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
// Handle real backend format: {rev: 0, data: {...}}
|
|
else if (update?.data && update?.rev !== undefined) {
|
|
data.value = update.data
|
|
hasLoadedInitialData.value = true
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
// Handle patch updates (both backends)
|
|
else if (data.value && update?.patch) {
|
|
try {
|
|
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
|
|
data.value = applyDataPatch(data.value, update.patch)
|
|
// Mark as connected once we receive any valid patch
|
|
if (!isConnected.value) {
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Now connect (or reconnect if already connected)
|
|
// Only attempt to connect if not already connected
|
|
if (wsClient.isConnected()) {
|
|
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
// Re-entrant call while already connected (e.g. after onboarding
|
|
// completes): resync state in case a broadcast was missed.
|
|
void refreshStateFromServer()
|
|
startStalenessReconciliation()
|
|
return
|
|
}
|
|
|
|
await wsClient.connect()
|
|
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
|
|
|
|
// Fetch fresh state after reconnect to avoid stale patch application
|
|
await refreshStateFromServer()
|
|
startStalenessReconciliation()
|
|
|
|
// Connection state will be updated via the callback
|
|
if (wsClient.isConnected()) {
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('[Store] WebSocket connection failed:', err)
|
|
// Don't mark as disconnected immediately - let reconnection logic handle it
|
|
// The WebSocket client will retry automatically
|
|
isReconnecting.value = true
|
|
isConnected.value = false
|
|
// Don't throw - allow app to work without real-time updates
|
|
// The WebSocket will reconnect in the background
|
|
} finally {
|
|
isWsConnecting = false
|
|
}
|
|
}
|
|
|
|
async function initializeData(): Promise<void> {
|
|
// Initialize with empty data structure
|
|
// The WebSocket will populate it with real data
|
|
data.value = {
|
|
'server-info': {
|
|
id: '',
|
|
version: '',
|
|
name: null,
|
|
pubkey: '',
|
|
'status-info': {
|
|
restarting: false,
|
|
'shutting-down': false,
|
|
updated: false,
|
|
'backup-progress': null,
|
|
'update-progress': null,
|
|
},
|
|
'lan-address': null,
|
|
'tor-address': null,
|
|
unread: 0,
|
|
'wifi-ssids': [],
|
|
'zram-enabled': false,
|
|
'seed-backed': false,
|
|
},
|
|
'package-data': {},
|
|
ui: {
|
|
name: null,
|
|
'ack-welcome': '',
|
|
marketplace: {
|
|
'selected-hosts': [],
|
|
'known-hosts': {},
|
|
},
|
|
theme: 'dark',
|
|
},
|
|
}
|
|
hasLoadedInitialData.value = false
|
|
}
|
|
|
|
/** Reset sync state on logout — called by auth store */
|
|
function resetOnLogout(): void {
|
|
stopStalenessReconciliation()
|
|
data.value = null
|
|
hasLoadedInitialData.value = false
|
|
isWsSubscribed = false
|
|
wsClient.disconnect()
|
|
isConnected.value = false
|
|
isReconnecting.value = false
|
|
}
|
|
|
|
return {
|
|
// State
|
|
data,
|
|
isConnected,
|
|
isReconnecting,
|
|
hasLoadedInitialData,
|
|
|
|
// Computed
|
|
serverInfo,
|
|
packages,
|
|
peerHealth,
|
|
uiData,
|
|
|
|
// Actions
|
|
connectWebSocket,
|
|
initializeData,
|
|
resetOnLogout,
|
|
}
|
|
})
|