Merge branch 'iso-feedback-fixes-2026-07-02' into merge-iso-feedback

# Conflicts:
#	core/archipelago/src/api/rpc/middleware.rs
This commit is contained in:
archipelago
2026-07-02 08:03:25 -04:00
64 changed files with 1513 additions and 208 deletions
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { useContainersScanTimeout } from '../useContainersScanTimeout'
describe('useContainersScanTimeout', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflects the real scanned flag when it arrives before the timeout', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
expect(effectiveContainersScanned.value).toBe(false)
scanned.value = true
await nextTick()
expect(effectiveContainersScanned.value).toBe(true)
expect(scanTimedOut.value).toBe(false)
})
it('does not start the timeout until initial data has loaded', async () => {
const scanned = ref(false)
const loaded = ref(false)
const { effectiveContainersScanned } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(60_000)
expect(effectiveContainersScanned.value).toBe(false)
loaded.value = true
await nextTick()
vi.advanceTimersByTime(20_000)
expect(effectiveContainersScanned.value).toBe(true)
})
it('falls through after the timeout even if the flag never arrives', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(19_999)
expect(effectiveContainersScanned.value).toBe(false)
vi.advanceTimersByTime(1)
expect(effectiveContainersScanned.value).toBe(true)
expect(scanTimedOut.value).toBe(true)
})
it('cancels the escape hatch when the real flag arrives', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(10_000)
scanned.value = true
await nextTick()
vi.advanceTimersByTime(60_000)
expect(scanTimedOut.value).toBe(false)
})
})
@@ -0,0 +1,56 @@
// Escape hatch for the "Checking containers…" / "Checking..." states.
//
// If the server never flips `containers-scanned` to true (e.g. the UI missed
// a websocket broadcast), views gating on it would spin forever. This starts
// a timeout once initial data has loaded and, if the scan flag is still false
// when it fires, treats the scan as complete so the UI falls through to its
// real empty/install states. A periodic store-level resync exists too — this
// is the belt-and-suspenders guarantee that the spinner is always bounded.
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from 'vue'
const DEFAULT_SCAN_TIMEOUT_MS = 20_000
export function useContainersScanTimeout(
containersScanned: Ref<boolean>,
hasLoadedInitialData: Ref<boolean>,
timeoutMs: number = DEFAULT_SCAN_TIMEOUT_MS,
) {
const scanTimedOut = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
function clearTimer(): void {
if (timer !== undefined) {
clearTimeout(timer)
timer = undefined
}
}
watch(
[containersScanned, hasLoadedInitialData],
([scanned, loaded]) => {
if (scanned) {
// Real signal arrived — cancel the escape hatch.
clearTimer()
scanTimedOut.value = false
return
}
if (loaded && timer === undefined && !scanTimedOut.value) {
timer = setTimeout(() => {
scanTimedOut.value = true
timer = undefined
}, timeoutMs)
}
},
{ immediate: true },
)
if (getCurrentInstance()) onBeforeUnmount(clearTimer)
/** True once the server reports the scan done OR the timeout has elapsed. */
const effectiveContainersScanned = computed(
() => containersScanned.value || scanTimedOut.value,
)
return { effectiveContainersScanned, scanTimedOut }
}
+1
View File
@@ -96,6 +96,7 @@
"serverStarting": "Server starting up...",
"replayIntro": "Replay Intro",
"onboarding": "Onboarding",
"restartConfirm": "Are you sure? This wipes onboarding progress — click again to confirm",
"resetting": "Resetting...",
"recoveryNote": "Password recovery requires SSH access to the server.",
"errorMinLength": "Password must be at least 8 characters",
+1
View File
@@ -96,6 +96,7 @@
"serverStarting": "El servidor est\u00e1 iniciando...",
"replayIntro": "Repetir introducci\u00f3n",
"onboarding": "Configuraci\u00f3n inicial",
"restartConfirm": "Are you sure? This wipes onboarding progress \u2014 click again to confirm",
"resetting": "Restableciendo...",
"recoveryNote": "La recuperaci\u00f3n de contrase\u00f1a requiere acceso SSH al servidor.",
"errorMinLength": "La contrase\u00f1a debe tener al menos 8 caracteres",
+51 -10
View File
@@ -14,6 +14,9 @@ export const useSyncStore = defineStore('sync', () => {
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'])
@@ -21,6 +24,47 @@ export const useSyncStore = defineStore('sync', () => {
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
@@ -82,6 +126,10 @@ export const useSyncStore = defineStore('sync', () => {
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
}
@@ -89,16 +137,8 @@ export const useSyncStore = defineStore('sync', () => {
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
// Fetch fresh state after reconnect to avoid stale patch application
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 fetch fresh state after reconnect')
}
await refreshStateFromServer()
startStalenessReconciliation()
// Connection state will be updated via the callback
if (wsClient.isConnected()) {
@@ -158,6 +198,7 @@ export const useSyncStore = defineStore('sync', () => {
/** Reset sync state on logout — called by auth store */
function resetOnLogout(): void {
stopStalenessReconciliation()
data.value = null
hasLoadedInitialData.value = false
isWsSubscribed = false
+9 -1
View File
@@ -397,6 +397,7 @@ import { useAppsActions } from './apps/useAppsActions'
import { validateSideloadRequest } from './apps/sideloadValidation'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import {
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
@@ -461,7 +462,14 @@ const ALL_CATEGORIES = computed(() => buildAllCategories(t))
const SERVICE_CATEGORIES = computed(() => buildServiceCategories(t))
const livePackages = computed(() => store.packages || {})
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false)
// Field missing from server data = not scanned yet (consistent with Discover/Marketplace)
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
// Escape hatch: never show "Checking containers…" forever — after a timeout,
// fall through to the real (empty) state even if the scanned flag never arrives.
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
containersScannedRaw,
computed(() => store.hasLoadedInitialData),
)
const {
packages: stablePackages,
isUsingLastKnownPackages,
+3 -3
View File
@@ -178,12 +178,12 @@ const backgroundImage = computed(() => {
if (mapped) return mapped
// Detail/sub pages inherit their parent tab's background so they stay
// visually "inside" the section instead of snapping to the home backdrop.
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.jpg'
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.webp'
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.jpg'
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.webp'
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
return 'bg-home.jpg'
return 'bg-home.webp'
})
const isDarkRoute = computed(() => {
+8 -1
View File
@@ -235,6 +235,7 @@ import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { APP_STORE_SECTIONS } from './appStoreCategories'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
@@ -335,7 +336,13 @@ function retryNostr() {
}
const installedPackages = computed(() => store.data?.['package-data'] || {})
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
// Escape hatch: never leave app cards on "Checking..." forever — after a
// timeout, treat the scan as done so cards render their normal install state.
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
containersScannedRaw,
computed(() => store.hasLoadedInitialData),
)
const allApps = computed(() => {
+28 -4
View File
@@ -66,7 +66,8 @@
data-form-type="other"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.enterPasswordSetup')"
@keydown.enter="handleSetupWithSound"
@keydown.enter="confirmPasswordInputRef?.focus()"
@input="error = null"
:disabled="loading || formDisabled"
/>
</div>
@@ -77,6 +78,7 @@
</label>
<input
id="setup-confirm-password"
ref="confirmPasswordInputRef"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
@@ -84,14 +86,16 @@
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.confirmPasswordPlaceholder')"
@keydown.enter="handleSetupWithSound"
@input="error = null"
:disabled="loading || formDisabled"
/>
</div>
<button
@click="handleSetupWithSound"
:disabled="loading || formDisabled || !password || password.length < 8 || password !== confirmPassword"
:disabled="loading || formDisabled"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
:class="{ 'opacity-60': !password || password.length < 8 || password !== confirmPassword }"
>
<span v-if="!loading">{{ t('login.setupButton') }}</span>
<span v-else class="flex items-center justify-center">
@@ -213,9 +217,10 @@
<button
@click="restartOnboarding"
:disabled="isResettingOnboarding"
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
class="text-xs transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
:class="confirmingRestartOnboarding ? 'text-orange-400 hover:text-orange-300' : 'text-white/50 hover:text-white/70'"
>
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
{{ isResettingOnboarding ? t('login.resetting') : (confirmingRestartOnboarding ? t('login.restartConfirm') : t('login.onboarding')) }}
</button>
</template>
</div>
@@ -260,6 +265,7 @@ const requiresTotp = ref(false)
const totpCode = ref('')
const useBackupCode = ref(false)
const totpInputRef = ref<HTMLInputElement | null>(null)
const confirmPasswordInputRef = ref<HTMLInputElement | null>(null)
// Server startup state
const serverReady = ref(false)
@@ -338,6 +344,7 @@ onBeforeUnmount(() => {
removeUnlockListeners()
if (startupPollTimer) clearTimeout(startupPollTimer)
if (startupProgressInterval) clearInterval(startupProgressInterval)
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
})
onMounted(async () => {
@@ -536,9 +543,26 @@ function replayIntro() {
}
const isResettingOnboarding = ref(false)
const confirmingRestartOnboarding = ref(false)
let confirmRestartTimer: ReturnType<typeof setTimeout> | null = null
async function restartOnboarding() {
if (isResettingOnboarding.value) return
// First click arms a confirmation state; only a second explicit click restarts.
if (!confirmingRestartOnboarding.value) {
confirmingRestartOnboarding.value = true
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
confirmRestartTimer = setTimeout(() => {
confirmingRestartOnboarding.value = false
confirmRestartTimer = null
}, 5000)
return
}
if (confirmRestartTimer) {
clearTimeout(confirmRestartTimer)
confirmRestartTimer = null
}
confirmingRestartOnboarding.value = false
isResettingOnboarding.value = true
// Local-only reset — no RPC needed since user isn't logged in.
// Onboarding pages are all public, so clearing localStorage is enough.
+8 -1
View File
@@ -170,6 +170,7 @@ import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import {
@@ -286,9 +287,15 @@ const installedPackages = computed(() => {
return store.data?.['package-data'] || {}
})
const containersScanned = computed(() => {
const containersScannedRaw = computed(() => {
return store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false
})
// Escape hatch: never leave app cards on "Checking..." forever after a
// timeout, treat the scan as done so cards render their normal install state.
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
containersScannedRaw,
computed(() => store.hasLoadedInitialData),
)
// Combine curated apps with Nostr relay-discovered apps
const allApps = computed(() => {
+13 -13
View File
@@ -109,16 +109,16 @@ const useVideoBackground = computed(() => {
// Note: bg-intro.jpg is used for splash and /onboarding/intro for seamless transition
const routeBackgrounds: Record<string, string> = {
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
'/onboarding/options': 'bg-intro-4.jpg',
'/onboarding/options': 'bg-intro-4.webp',
'/onboarding/path': 'bg-intro-3.jpg',
'/onboarding/seed': 'bg-intro-1.jpg',
'/onboarding/seed-verify': 'bg-intro-1.jpg',
'/onboarding/seed-restore': 'bg-intro-1.jpg',
'/onboarding/did': 'bg-intro-4.jpg',
'/onboarding/identity': 'bg-intro-1.jpg',
'/onboarding/backup': 'bg-intro-6.jpg',
'/onboarding/seed': 'bg-intro-1.webp',
'/onboarding/seed-verify': 'bg-intro-1.webp',
'/onboarding/seed-restore': 'bg-intro-1.webp',
'/onboarding/did': 'bg-intro-4.webp',
'/onboarding/identity': 'bg-intro-1.webp',
'/onboarding/backup': 'bg-intro-6.webp',
'/onboarding/verify': 'bg-intro-2.jpg',
'/onboarding/done': 'bg-intro-1.jpg',
'/onboarding/done': 'bg-intro-1.webp',
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
}
@@ -126,12 +126,12 @@ const routeBackgrounds: Record<string, string> = {
// identical on every logout. Cycles through bg-intro-1..6 using a
// counter persisted to localStorage so subsequent visits advance.
const LOGIN_BACKGROUNDS = [
'bg-intro-1.jpg',
'bg-intro-1.webp',
'bg-intro-2.jpg',
'bg-intro-3.jpg',
'bg-intro-4.jpg',
'bg-intro-5.jpg',
'bg-intro-6.jpg',
'bg-intro-4.webp',
'bg-intro-5.webp',
'bg-intro-6.webp',
]
function pickNextLoginBackground(): string {
try {
@@ -307,7 +307,7 @@ watch(() => route.path, (newPath, oldPath) => {
// Login route: set background immediately, no zoom, no transition (glitch is always-on)
if (newPath === '/login') {
currentBackground.value = 'bg-intro-1.jpg'
currentBackground.value = 'bg-intro-1.webp'
isTransitioning.value = false
isGlitching.value = false
return
@@ -19,19 +19,19 @@ const WEB5_TAB_ORDER = ['/dashboard/web5', '/dashboard/cloud', '/dashboard/serve
/** Route-to-background image mapping */
export const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard': 'bg-home.jpg',
'/dashboard/': 'bg-home.jpg',
'/dashboard/apps': 'bg-myapps.jpg',
'/dashboard/discover': 'bg-appstore.jpg',
'/dashboard/marketplace': 'bg-appstore.jpg',
'/dashboard/cloud': 'bg-cloud.jpg',
'/dashboard/mesh': 'bg-mesh.jpg',
'/dashboard': 'bg-home.webp',
'/dashboard/': 'bg-home.webp',
'/dashboard/apps': 'bg-myapps.webp',
'/dashboard/discover': 'bg-appstore.webp',
'/dashboard/marketplace': 'bg-appstore.webp',
'/dashboard/cloud': 'bg-cloud.webp',
'/dashboard/mesh': 'bg-mesh.webp',
'/dashboard/server': 'bg-network.jpg',
'/dashboard/web5': 'bg-web5.jpg',
'/dashboard/server/federation': 'bg-web5.jpg',
'/dashboard/monitoring': 'bg-web5.jpg',
'/dashboard/fleet': 'bg-web5.jpg',
'/dashboard/settings': 'bg-settings.jpg',
'/dashboard/settings': 'bg-settings.webp',
'/dashboard/chat': 'bg-aiui.jpg',
}