Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '../aiPermissions'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ai-permissions'
|
||||
|
||||
describe('useAIPermissionsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with empty permissions when no localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.allEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('loads valid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
expect(store.enabled.size).toBe(2)
|
||||
})
|
||||
|
||||
it('filters invalid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'invalid-category', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(2)
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles corrupt localStorage gracefully', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-valid-json{')
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('toggle adds a category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle removes an enabled category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggle persists to localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
|
||||
expect(stored).toContain('apps')
|
||||
})
|
||||
|
||||
it('enableAll enables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
expect(store.allEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(AI_PERMISSION_CATEGORIES.length)
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(store.isEnabled(cat.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('disableAll disables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
store.disableAll()
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('enabledCategories returns array of enabled IDs', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
store.toggle('network')
|
||||
expect(store.enabledCategories).toContain('apps')
|
||||
expect(store.enabledCategories).toContain('network')
|
||||
expect(store.enabledCategories.length).toBe(2)
|
||||
})
|
||||
|
||||
it('AI_PERMISSION_CATEGORIES has 10 categories', () => {
|
||||
expect(AI_PERMISSION_CATEGORIES.length).toBe(10)
|
||||
})
|
||||
|
||||
it('all categories have required fields', () => {
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(cat.id).toBeTruthy()
|
||||
expect(cat.label).toBeTruthy()
|
||||
expect(cat.description).toBeTruthy()
|
||||
expect(cat.icon).toBeTruthy()
|
||||
expect(cat.group).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
call: vi.fn(),
|
||||
installPackage: vi.fn(),
|
||||
uninstallPackage: vi.fn(),
|
||||
startPackage: vi.fn(),
|
||||
stopPackage: vi.fn(),
|
||||
restartPackage: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
restartServer: vi.fn(),
|
||||
shutdownServer: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
getMarketplace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the websocket module
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(false),
|
||||
onConnectionStateChange: vi.fn(),
|
||||
},
|
||||
applyDataPatch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useAppStore } from '../app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
const mockedWs = vi.mocked(wsClient)
|
||||
|
||||
describe('useAppStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockedWs.isConnected.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('starts with default unauthenticated state', () => {
|
||||
const store = useAppStore()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.isConnected).toBe(false)
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.data).toBeNull()
|
||||
})
|
||||
|
||||
it('login succeeds and sets authenticated state', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.login('password123')
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('neode-auth')).toBe('true')
|
||||
expect(store.data).not.toBeNull()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('login handles TOTP requirement', async () => {
|
||||
mockedRpc.login.mockResolvedValue({ requires_totp: true })
|
||||
const store = useAppStore()
|
||||
|
||||
const result = await store.login('password123')
|
||||
|
||||
expect(result).toEqual({ requires_totp: true })
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('login sets error on failure', async () => {
|
||||
mockedRpc.login.mockRejectedValue(new Error('Invalid password'))
|
||||
const store = useAppStore()
|
||||
|
||||
await expect(store.login('wrong')).rejects.toThrow('Invalid password')
|
||||
|
||||
expect(store.error).toBe('Invalid password')
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('logout clears all state', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
mockedRpc.logout.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
|
||||
// Login first
|
||||
await store.login('password123')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
|
||||
// Then logout
|
||||
await store.logout()
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.data).toBeNull()
|
||||
expect(store.isConnected).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
expect(mockedWs.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logout still clears state even if RPC fails', async () => {
|
||||
mockedRpc.logout.mockRejectedValue(new Error('Network error'))
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
const store = useAppStore()
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
})
|
||||
|
||||
it('checkSession returns true on valid session', async () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
mockedRpc.call.mockResolvedValue('ping')
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(true)
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.data).not.toBeNull()
|
||||
})
|
||||
|
||||
it('checkSession returns false when no auth in localStorage', async () => {
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(false)
|
||||
})
|
||||
|
||||
it('checkSession returns false and clears state on expired session', async () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
mockedRpc.call.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
const store = useAppStore()
|
||||
|
||||
const valid = await store.checkSession()
|
||||
|
||||
expect(valid).toBe(false)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('neode-auth')).toBeNull()
|
||||
expect(mockedWs.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connectWebSocket subscribes and connects', async () => {
|
||||
mockedWs.connect.mockResolvedValue(undefined)
|
||||
// First call: not connected (triggers connect), second call: connected (after connect)
|
||||
mockedWs.isConnected.mockReturnValueOnce(false).mockReturnValue(true)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.connectWebSocket()
|
||||
|
||||
expect(mockedWs.subscribe).toHaveBeenCalledOnce()
|
||||
expect(mockedWs.onConnectionStateChange).toHaveBeenCalledOnce()
|
||||
expect(mockedWs.connect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('needsSessionValidation returns true when auth but not validated', () => {
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
const store = useAppStore()
|
||||
|
||||
// isAuthenticated is true from localStorage, but sessionValidated is false
|
||||
expect(store.needsSessionValidation()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// vi.hoisted runs before vi.mock hoisting
|
||||
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockWindowOpen: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock vue-router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
import { useAppLauncherStore } from '../appLauncher'
|
||||
|
||||
describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
// Default to HTTP to avoid proxy rewriting
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://192.168.1.228', protocol: 'http:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 1024,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts closed with empty state', () => {
|
||||
const store = useAppLauncherStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
expect(store.title).toBe('')
|
||||
})
|
||||
|
||||
describe('companion app (native bridge)', () => {
|
||||
const openInApp = vi.fn()
|
||||
beforeEach(() => {
|
||||
;(window as any).ArchipelagoNative = { openInApp, openExternal: vi.fn() }
|
||||
openInApp.mockClear()
|
||||
})
|
||||
afterEach(() => {
|
||||
delete (window as any).ArchipelagoNative
|
||||
})
|
||||
|
||||
it('openSession hands iframeable apps to the native WebView, never the iframe session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
store.openSession('filebrowser')
|
||||
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining(':8083'))
|
||||
expect(store.panelAppId).toBeNull()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('openSession carries deep-link paths into the WebView URL', () => {
|
||||
const store = useAppLauncherStore()
|
||||
store.openSession('mempool', { path: '/tx/abc123' })
|
||||
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining('/tx/abc123'))
|
||||
expect(store.panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('open() never falls through to the iframe overlay', () => {
|
||||
const store = useAppLauncherStore()
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown app' })
|
||||
expect(openInApp).toHaveBeenCalledWith('http://192.168.1.228:9999')
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('routes known port apps to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Port 8083 maps to /app/filebrowser/ — should route to session
|
||||
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
|
||||
|
||||
// Default panel mode: sets panelAppId, doesn't open overlay
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe('filebrowser')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the store-driven panel on mobile (no route change, no background swap)', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.openSession('indeedhub')
|
||||
|
||||
// Mobile now uses the store-driven panel like desktop panel mode so the
|
||||
// underlying page/tab never changes and closing returns to the origin.
|
||||
expect(store.panelAppId).toBe('indeedhub')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes localhost launch URLs to current host before resolving', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://localhost:4080', title: 'Mempool' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe('mempool')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes localhost IndeeHub URLs to current host before resolving', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://localhost:7778', title: 'IndeeHub' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe('indeedhub')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes BTCPay (port 23000) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:23000',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:8081',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens tab-only apps directly on mobile (new tab in PWA, no interstitial)', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
|
||||
|
||||
// Tab-only app on mobile-web: open directly in a new browser tab (the
|
||||
// companion would use the in-app WebView). No session, no route push, no
|
||||
// "this app opens in a tab" interstitial.
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:8081',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://192.168.1.228/app/nginx-proxy-manager/', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://192.168.1.228/app/nginx-proxy-manager/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
})
|
||||
|
||||
it('normalizes legacy Nginx Proxy Manager ports to 8081', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:8081',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes legacy Uptime Kuma port 3001 to 3002', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:3001', title: 'Uptime Kuma' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:3002',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens Uptime Kuma in new tab using title hint when URL is path-only', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://192.168.1.228/app/uptime-kuma/', title: 'Uptime Kuma' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://192.168.1.228/app/uptime-kuma/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
})
|
||||
|
||||
it('routes Home Assistant (port 8123) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:8123',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes Grafana (port 3000) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:3000',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Gitea path URL in new tab', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228/app/gitea/', title: 'Gitea' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228/app/gitea/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not map raw port 3001 to gitea session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:3001', title: 'Unknown 3001' })
|
||||
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('opens in new tab when openInNewTab flag is set for unknown URL', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Use an unresolvable URL so it doesn't route to session
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:9999',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens known prepackaged websites in new tab on desktop when requested', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://botfights.net', title: 'BotFights', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://botfights.net',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens prepackaged websites in the store-driven panel on mobile', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://botfights.net', title: 'BotFights', openInNewTab: true })
|
||||
|
||||
// Iframeable prepackaged sites stay in-app via the store panel (no route
|
||||
// change, no background swap) just like every other mobile launch.
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe('botfights')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes HTTPS same-host apps via session view', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
|
||||
|
||||
// Known port — routes to session (panel mode by default)
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe('filebrowser')
|
||||
})
|
||||
|
||||
it('opens unknown URL in iframe overlay on HTTP', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
// Unresolvable URL — falls through to iframe overlay
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom App' })
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.url).toBe('http://192.168.1.228:9999')
|
||||
expect(store.title).toBe('Custom App')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens unknown different-host URL in iframe overlay', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.100:9999', title: 'Remote App' })
|
||||
|
||||
// Different host, unknown port — opens in iframe overlay (no proxy rewrite)
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.url).toBe('http://192.168.1.100:9999')
|
||||
})
|
||||
|
||||
it('close resets state', () => {
|
||||
const store = useAppLauncherStore()
|
||||
// Use unknown URL to trigger iframe overlay
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
|
||||
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
expect(store.title).toBe('')
|
||||
})
|
||||
|
||||
it('close restores focus to previous element', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useAppLauncherStore()
|
||||
const mockButton = { focus: vi.fn() } as unknown as HTMLElement
|
||||
Object.defineProperty(document, 'activeElement', { value: mockButton, configurable: true })
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.url).toBe('')
|
||||
|
||||
// requestAnimationFrame fires the focus restore callback
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useCLIStore } from '../cli'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const mockedPlayNavSound = vi.mocked(playNavSound)
|
||||
|
||||
describe('useCLIStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts closed', () => {
|
||||
const store = useCLIStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('open sets isOpen to true and plays sound', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('close sets isOpen to false without sound', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
vi.clearAllMocks()
|
||||
store.close()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockedPlayNavSound).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggle opens and plays sound when closed', () => {
|
||||
const store = useCLIStore()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('toggle closes without sound when open', () => {
|
||||
const store = useCLIStore()
|
||||
store.open()
|
||||
vi.clearAllMocks()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockedPlayNavSound).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('multiple toggles alternate state', () => {
|
||||
const store = useCLIStore()
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(false)
|
||||
store.toggle()
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
login: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
downloadUrl: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useCloudStore } from '../cloud'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
|
||||
const mockedClient = vi.mocked(fileBrowserClient)
|
||||
|
||||
const mockItems = [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-02', isDir: false, type: '', extension: 'md' },
|
||||
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-03', isDir: false, type: '', extension: 'zip' },
|
||||
]
|
||||
|
||||
describe('useCloudStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with default state', () => {
|
||||
const store = useCloudStore()
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.authenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('init authenticates with filebrowser', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
const store = useCloudStore()
|
||||
|
||||
const result = await store.init()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.authenticated).toBe(true)
|
||||
expect(mockedClient.login).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('init returns false on auth failure', async () => {
|
||||
mockedClient.login.mockResolvedValue(false)
|
||||
const store = useCloudStore()
|
||||
|
||||
const result = await store.init()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.authenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('init skips login if already authenticated', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.init()
|
||||
await store.init()
|
||||
|
||||
expect(mockedClient.login).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('navigate loads items and updates path', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
mockedClient.listDirectory.mockResolvedValue(mockItems)
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
await store.navigate('/photos')
|
||||
|
||||
expect(store.items).toEqual(mockItems)
|
||||
expect(store.currentPath).toBe('/photos')
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('navigate authenticates if not authenticated', async () => {
|
||||
mockedClient.login.mockResolvedValue(true)
|
||||
mockedClient.listDirectory.mockResolvedValue(mockItems)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(mockedClient.login).toHaveBeenCalled()
|
||||
expect(store.items).toEqual(mockItems)
|
||||
})
|
||||
|
||||
it('navigate sets error on auth failure', async () => {
|
||||
mockedClient.login.mockResolvedValue(false)
|
||||
const store = useCloudStore()
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(store.error).toBe('Failed to authenticate with File Browser')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('navigate falls back to creating directory on list failure', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
// First listDirectory fails, then createFolder succeeds, then retry succeeds
|
||||
mockedClient.listDirectory
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce([])
|
||||
mockedClient.createFolder.mockResolvedValue(undefined)
|
||||
|
||||
await store.navigate('/new-folder')
|
||||
|
||||
expect(mockedClient.createFolder).toHaveBeenCalledWith('/', 'new-folder')
|
||||
expect(store.currentPath).toBe('/new-folder')
|
||||
})
|
||||
|
||||
it('navigate falls back to root when directory creation also fails', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
// Call 1: listDirectory('/deep/nested') rejects
|
||||
// Call 2: listDirectory('/') in the fallback catch resolves
|
||||
mockedClient.listDirectory
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce(mockItems)
|
||||
|
||||
mockedClient.createFolder.mockRejectedValueOnce(new Error('Create failed'))
|
||||
|
||||
await store.navigate('/deep/nested')
|
||||
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual(mockItems)
|
||||
})
|
||||
|
||||
it('navigate sets error when root listing fails', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
|
||||
mockedClient.listDirectory.mockRejectedValueOnce(new Error('Server error'))
|
||||
|
||||
await store.navigate('/')
|
||||
|
||||
expect(store.error).toBe('Failed to list root directory')
|
||||
})
|
||||
|
||||
it('breadcrumbs computes from path', () => {
|
||||
const store = useCloudStore()
|
||||
store.currentPath = '/photos/vacation/2026'
|
||||
|
||||
expect(store.breadcrumbs).toEqual([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'photos', path: '/photos' },
|
||||
{ name: 'vacation', path: '/photos/vacation' },
|
||||
{ name: '2026', path: '/photos/vacation/2026' },
|
||||
])
|
||||
})
|
||||
|
||||
it('breadcrumbs at root only shows Home', () => {
|
||||
const store = useCloudStore()
|
||||
expect(store.breadcrumbs).toEqual([{ name: 'Home', path: '/' }])
|
||||
})
|
||||
|
||||
it('sortedItems puts directories first, sorted alphabetically', () => {
|
||||
const store = useCloudStore()
|
||||
store.items = [
|
||||
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-01', isDir: false, type: '', extension: 'md' },
|
||||
{ name: 'docs', path: '/docs', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-01', isDir: false, type: '', extension: 'zip' },
|
||||
{ name: 'assets', path: '/assets', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
]
|
||||
|
||||
const sorted = store.sortedItems
|
||||
expect(sorted.map((i) => i.name)).toEqual(['assets', 'docs', 'archive.zip', 'readme.md'])
|
||||
})
|
||||
|
||||
it('uploadFile uploads and refreshes', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
store.currentPath = '/uploads'
|
||||
mockedClient.upload.mockResolvedValue(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
|
||||
const file = new File(['test'], 'test.txt')
|
||||
await store.uploadFile(file)
|
||||
|
||||
expect(mockedClient.upload).toHaveBeenCalledWith('/uploads', file)
|
||||
expect(mockedClient.listDirectory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deleteItem deletes and refreshes', async () => {
|
||||
const store = useCloudStore()
|
||||
store.authenticated = true
|
||||
store.currentPath = '/'
|
||||
mockedClient.deleteItem.mockResolvedValue(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
|
||||
await store.deleteItem('/old-file.txt')
|
||||
|
||||
expect(mockedClient.deleteItem).toHaveBeenCalledWith('/old-file.txt')
|
||||
expect(mockedClient.listDirectory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloadUrl delegates to filebrowser client', () => {
|
||||
mockedClient.downloadUrl.mockReturnValue('http://localhost/api/raw/file.txt?auth=token')
|
||||
const store = useCloudStore()
|
||||
|
||||
const url = store.downloadUrl('/file.txt')
|
||||
|
||||
expect(url).toBe('http://localhost/api/raw/file.txt?auth=token')
|
||||
expect(mockedClient.downloadUrl).toHaveBeenCalledWith('/file.txt')
|
||||
})
|
||||
|
||||
it('reset clears all state', () => {
|
||||
const store = useCloudStore()
|
||||
store.currentPath = '/deep/path'
|
||||
store.items = mockItems
|
||||
store.loading = true
|
||||
store.error = 'something'
|
||||
|
||||
store.reset()
|
||||
|
||||
expect(store.currentPath).toBe('/')
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/container-client', () => ({
|
||||
containerClient: {
|
||||
listContainers: vi.fn(),
|
||||
getHealthStatus: vi.fn(),
|
||||
installApp: vi.fn(),
|
||||
startContainer: vi.fn(),
|
||||
stopContainer: vi.fn(),
|
||||
removeContainer: vi.fn(),
|
||||
getContainerLogs: vi.fn(),
|
||||
getContainerStatus: vi.fn(),
|
||||
startBundledApp: vi.fn(),
|
||||
stopBundledApp: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useContainerStore } from '../container'
|
||||
import { containerClient } from '@/api/container-client'
|
||||
|
||||
const mockedClient = vi.mocked(containerClient)
|
||||
|
||||
const mockContainers = [
|
||||
{ id: '1', name: 'bitcoin-knots', state: 'running' as const, status: 'Up 2 hours', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'], lan_address: 'http://localhost:8332' },
|
||||
{ id: '2', name: 'lnd', state: 'stopped' as const, status: 'Exited (0)', image: 'lnd:v0.18.4', created: '2026-01-01', ports: ['9735'], lan_address: undefined },
|
||||
{ id: '3', name: 'mempool', state: 'running' as const, status: 'Up 1 hour', image: 'mempool:latest', created: '2026-01-01', ports: ['8080'], lan_address: 'http://localhost:8080' },
|
||||
]
|
||||
|
||||
describe('useContainerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchContainers loads container list', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.containers).toEqual(mockContainers)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchContainers sets error on failure', async () => {
|
||||
mockedClient.listContainers.mockRejectedValue(new Error('Connection refused'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.error).toBe('Connection refused')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('runningContainers filters correctly', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.runningContainers).toHaveLength(2)
|
||||
expect(store.runningContainers.map(c => c.name)).toEqual(['bitcoin-knots', 'mempool'])
|
||||
})
|
||||
|
||||
it('stoppedContainers filters correctly', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.stoppedContainers).toHaveLength(1)
|
||||
expect(store.stoppedContainers[0]!.name).toBe('lnd')
|
||||
})
|
||||
|
||||
it('startContainer calls client and refreshes', async () => {
|
||||
mockedClient.startContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({ 'bitcoin-knots': 'healthy' })
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.startContainer('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.startContainer).toHaveBeenCalledWith('bitcoin-knots')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(mockedClient.getHealthStatus).toHaveBeenCalled()
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopContainer calls client and refreshes', async () => {
|
||||
mockedClient.stopContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.stopContainer('lnd')
|
||||
|
||||
expect(mockedClient.stopContainer).toHaveBeenCalledWith('lnd')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
})
|
||||
|
||||
it('getAppState returns correct states', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getAppState('bitcoin-knots')).toBe('running')
|
||||
expect(store.getAppState('lnd')).toBe('stopped')
|
||||
expect(store.getAppState('nonexistent')).toBe('not-installed')
|
||||
})
|
||||
|
||||
it('isAppLoading tracks per-app loading state', async () => {
|
||||
let resolveStart: (() => void) | undefined
|
||||
mockedClient.startContainer.mockImplementation(() => new Promise(r => { resolveStart = r }))
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({})
|
||||
const store = useContainerStore()
|
||||
|
||||
const startPromise = store.startContainer('bitcoin-knots')
|
||||
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(true)
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
|
||||
resolveStart!()
|
||||
await startPromise
|
||||
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchHealthStatus loads health data', async () => {
|
||||
mockedClient.getHealthStatus.mockResolvedValue({ 'bitcoin-knots': 'healthy', 'lnd': 'degraded' })
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchHealthStatus()
|
||||
|
||||
expect(store.getHealthStatus('bitcoin-knots')).toBe('healthy')
|
||||
expect(store.getHealthStatus('lnd')).toBe('degraded')
|
||||
expect(store.getHealthStatus('unknown-app')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('fetchHealthStatus handles errors silently', async () => {
|
||||
mockedClient.getHealthStatus.mockRejectedValue(new Error('fail'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchHealthStatus()
|
||||
// Should not throw, healthStatus stays empty
|
||||
expect(store.healthStatus).toEqual({})
|
||||
})
|
||||
|
||||
it('installApp installs and refreshes containers', async () => {
|
||||
mockedClient.installApp.mockResolvedValue('new-app')
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
|
||||
const result = await store.installApp('/path/to/manifest')
|
||||
|
||||
expect(result).toBe('new-app')
|
||||
expect(mockedClient.installApp).toHaveBeenCalledWith('/path/to/manifest')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('installApp sets error and rethrows on failure', async () => {
|
||||
mockedClient.installApp.mockRejectedValue(new Error('Install failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.installApp('/bad/manifest')).rejects.toThrow('Install failed')
|
||||
expect(store.error).toBe('Install failed')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('startContainer sets error on failure', async () => {
|
||||
mockedClient.startContainer.mockRejectedValue(new Error('Start failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.startContainer('bitcoin-knots')).rejects.toThrow('Start failed')
|
||||
expect(store.error).toBe('Start failed')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopContainer sets error on failure', async () => {
|
||||
mockedClient.stopContainer.mockRejectedValue(new Error('Stop failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.stopContainer('lnd')).rejects.toThrow('Stop failed')
|
||||
expect(store.error).toBe('Stop failed')
|
||||
expect(store.isAppLoading('lnd')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeContainer removes and refreshes', async () => {
|
||||
mockedClient.removeContainer.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue([])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.removeContainer('old-app')
|
||||
|
||||
expect(mockedClient.removeContainer).toHaveBeenCalledWith('old-app')
|
||||
expect(mockedClient.listContainers).toHaveBeenCalled()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('removeContainer sets error on failure', async () => {
|
||||
mockedClient.removeContainer.mockRejectedValue(new Error('Remove failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.removeContainer('old-app')).rejects.toThrow('Remove failed')
|
||||
expect(store.error).toBe('Remove failed')
|
||||
})
|
||||
|
||||
it('getContainerLogs returns logs', async () => {
|
||||
mockedClient.getContainerLogs.mockResolvedValue(['line1', 'line2', 'line3'])
|
||||
const store = useContainerStore()
|
||||
|
||||
const logs = await store.getContainerLogs('bitcoin-knots', 50)
|
||||
|
||||
expect(logs).toEqual(['line1', 'line2', 'line3'])
|
||||
expect(mockedClient.getContainerLogs).toHaveBeenCalledWith('bitcoin-knots', 50)
|
||||
})
|
||||
|
||||
it('getContainerLogs defaults to 100 lines', async () => {
|
||||
mockedClient.getContainerLogs.mockResolvedValue(['log output'])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.getContainerLogs('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.getContainerLogs).toHaveBeenCalledWith('bitcoin-knots', 100)
|
||||
})
|
||||
|
||||
it('getContainerLogs sets error on failure', async () => {
|
||||
mockedClient.getContainerLogs.mockRejectedValue(new Error('Log error'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.getContainerLogs('bitcoin-knots')).rejects.toThrow('Log error')
|
||||
expect(store.error).toBe('Log error')
|
||||
})
|
||||
|
||||
it('getContainerStatus returns status', async () => {
|
||||
const status = { name: 'bitcoin-knots', state: 'running', uptime: '5h' }
|
||||
mockedClient.getContainerStatus.mockResolvedValue(status as never)
|
||||
const store = useContainerStore()
|
||||
|
||||
const result = await store.getContainerStatus('bitcoin-knots')
|
||||
|
||||
expect(result).toEqual(status)
|
||||
})
|
||||
|
||||
it('getContainerStatus sets error on failure', async () => {
|
||||
mockedClient.getContainerStatus.mockRejectedValue(new Error('Status error'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.getContainerStatus('bitcoin-knots')).rejects.toThrow('Status error')
|
||||
expect(store.error).toBe('Status error')
|
||||
})
|
||||
|
||||
it('startBundledApp starts and refreshes', async () => {
|
||||
mockedClient.startBundledApp.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
mockedClient.getHealthStatus.mockResolvedValue({})
|
||||
const store = useContainerStore()
|
||||
|
||||
const app = { id: 'bitcoin-knots', name: 'Bitcoin Knots', image: 'btc:29', description: '', icon: '', ports: [], volumes: [], category: 'bitcoin' as const }
|
||||
await store.startBundledApp(app)
|
||||
|
||||
expect(mockedClient.startBundledApp).toHaveBeenCalledWith(app)
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('startBundledApp sets error on failure', async () => {
|
||||
mockedClient.startBundledApp.mockRejectedValue(new Error('Start failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
const app = { id: 'test', name: 'Test', image: 'test:1', description: '', icon: '', ports: [], volumes: [], category: 'other' as const }
|
||||
await expect(store.startBundledApp(app)).rejects.toThrow('Start failed')
|
||||
expect(store.error).toBe('Start failed')
|
||||
expect(store.isAppLoading('test')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopBundledApp stops and refreshes', async () => {
|
||||
mockedClient.stopBundledApp.mockResolvedValue(undefined)
|
||||
mockedClient.listContainers.mockResolvedValue([])
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.stopBundledApp('bitcoin-knots')
|
||||
|
||||
expect(mockedClient.stopBundledApp).toHaveBeenCalledWith('bitcoin-knots')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('stopBundledApp sets error on failure', async () => {
|
||||
mockedClient.stopBundledApp.mockRejectedValue(new Error('Stop failed'))
|
||||
const store = useContainerStore()
|
||||
|
||||
await expect(store.stopBundledApp('bitcoin-knots')).rejects.toThrow('Stop failed')
|
||||
expect(store.error).toBe('Stop failed')
|
||||
expect(store.isAppLoading('bitcoin-knots')).toBe(false)
|
||||
})
|
||||
|
||||
it('getContainerById finds by name substring', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getContainerById('bitcoin')?.name).toBe('bitcoin-knots')
|
||||
expect(store.getContainerById('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getContainerForApp matches by exact name', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.getContainerForApp('bitcoin-knots')?.name).toBe('bitcoin-knots')
|
||||
expect(store.getContainerForApp('lnd')?.name).toBe('lnd')
|
||||
})
|
||||
|
||||
it('enrichedBundledApps includes lan_address from matching containers', async () => {
|
||||
mockedClient.listContainers.mockResolvedValue(mockContainers)
|
||||
const store = useContainerStore()
|
||||
await store.fetchContainers()
|
||||
|
||||
const enriched = store.enrichedBundledApps
|
||||
const btc = enriched.find(a => a.id === 'bitcoin-knots')
|
||||
expect(btc?.lan_address).toBe('http://localhost:8332')
|
||||
})
|
||||
|
||||
it('fetchContainers handles non-Error exceptions', async () => {
|
||||
mockedClient.listContainers.mockRejectedValue('string error')
|
||||
const store = useContainerStore()
|
||||
|
||||
await store.fetchContainers()
|
||||
|
||||
expect(store.error).toBe('Failed to fetch containers')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useControllerStore } from '../controller'
|
||||
|
||||
describe('useControllerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with default state', () => {
|
||||
const store = useControllerStore()
|
||||
expect(store.isActive).toBe(false)
|
||||
expect(store.gamepadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('setActive sets isActive to true', () => {
|
||||
const store = useControllerStore()
|
||||
store.setActive(true)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('setActive sets isActive to false', () => {
|
||||
const store = useControllerStore()
|
||||
store.setActive(true)
|
||||
store.setActive(false)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('setGamepadCount updates count and activates when > 0', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(2)
|
||||
expect(store.gamepadCount).toBe(2)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('setGamepadCount deactivates when count is 0', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(1)
|
||||
expect(store.isActive).toBe(true)
|
||||
store.setGamepadCount(0)
|
||||
expect(store.gamepadCount).toBe(0)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('setActive does not affect gamepadCount', () => {
|
||||
const store = useControllerStore()
|
||||
store.setGamepadCount(3)
|
||||
store.setActive(false)
|
||||
expect(store.isActive).toBe(false)
|
||||
expect(store.gamepadCount).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the app store
|
||||
const mockPackages: Record<string, { state: string }> = {}
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
packages: mockPackages,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the goals data with a controlled set
|
||||
vi.mock('@/data/goals', () => ({
|
||||
GOALS: [
|
||||
{
|
||||
id: 'accept-payments',
|
||||
title: 'Accept Payments',
|
||||
subtitle: 'Receive Bitcoin and Lightning payments',
|
||||
icon: 'payments',
|
||||
category: 'payments',
|
||||
requiredApps: ['bitcoin-knots', 'lnd'],
|
||||
steps: [
|
||||
{ id: 'install-bitcoin', title: 'Install Bitcoin', description: '', appId: 'bitcoin-knots', action: 'install', isAutomatic: true },
|
||||
{ id: 'install-lnd', title: 'Install LND', description: '', appId: 'lnd', action: 'install', isAutomatic: true },
|
||||
{ id: 'open-channel', title: 'Open Channel', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~30 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'create-identity',
|
||||
title: 'Create Identity',
|
||||
subtitle: 'Sovereign digital identity',
|
||||
icon: 'identity',
|
||||
category: 'identity',
|
||||
requiredApps: [],
|
||||
steps: [
|
||||
{ id: 'generate-did', title: 'Generate DID', description: '', action: 'verify', isAutomatic: true },
|
||||
{ id: 'setup-nostr', title: 'Setup Nostr', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~5 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-photos',
|
||||
title: 'Store Photos',
|
||||
subtitle: 'Private photo backup',
|
||||
icon: 'photos',
|
||||
category: 'storage',
|
||||
requiredApps: ['immich'],
|
||||
steps: [
|
||||
{ id: 'install-immich', title: 'Install Immich', description: '', appId: 'immich', action: 'install', isAutomatic: true },
|
||||
{ id: 'configure-immich', title: 'Configure', description: '', action: 'configure', isAutomatic: false },
|
||||
],
|
||||
estimatedTime: '~15 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
import { useGoalStore } from '../goals'
|
||||
|
||||
describe('useGoalStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
// Clear mock packages
|
||||
Object.keys(mockPackages).forEach((k) => delete mockPackages[k])
|
||||
})
|
||||
|
||||
it('starts with empty progress', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.progress).toEqual({})
|
||||
})
|
||||
|
||||
it('loads progress from localStorage', () => {
|
||||
const savedProgress = {
|
||||
'accept-payments': {
|
||||
goalId: 'accept-payments',
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 1,
|
||||
completedSteps: ['install-bitcoin'],
|
||||
startedAt: 1000,
|
||||
},
|
||||
}
|
||||
localStorage.setItem('archipelago-goal-progress', JSON.stringify(savedProgress))
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
|
||||
it('handles corrupt localStorage data', () => {
|
||||
localStorage.setItem('archipelago-goal-progress', 'not-valid-json{{{')
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.progress).toEqual({})
|
||||
})
|
||||
|
||||
it('startGoal creates progress entry and saves', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.status).toBe('in-progress')
|
||||
expect(store.progress['accept-payments']!.currentStepIndex).toBe(0)
|
||||
expect(store.progress['accept-payments']!.completedSteps).toEqual([])
|
||||
expect(localStorage.getItem('archipelago-goal-progress')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('completeStep adds step to completedSteps', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
|
||||
it('completeStep does not duplicate step IDs', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']!.completedSteps.filter((s) => s === 'install-bitcoin')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('completeStep marks goal completed when all steps done', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
store.completeStep('accept-payments', 'install-lnd')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
|
||||
expect(store.progress['accept-payments']!.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('completeStep is a no-op when goal not started', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
store.completeStep('accept-payments', 'install-bitcoin')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resetGoal removes progress entry', () => {
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
|
||||
store.resetGoal('accept-payments')
|
||||
|
||||
expect(store.progress['accept-payments']).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('getGoalStatus', () => {
|
||||
it('returns not-started for unknown goal', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('nonexistent')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns not-started when no apps installed and no progress', () => {
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('not-started')
|
||||
})
|
||||
|
||||
it('returns completed when all required apps run AND manual steps are done', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
// Running apps alone no longer finish a goal — manual steps must be walked
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('in-progress')
|
||||
|
||||
store.startGoal('accept-payments')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns in-progress when some required apps are installed', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
expect(store.getGoalStatus('accept-payments')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('uses manual progress for goals without required apps', () => {
|
||||
const store = useGoalStore()
|
||||
|
||||
// create-identity has no required apps
|
||||
expect(store.getGoalStatus('create-identity')).toBe('not-started')
|
||||
|
||||
store.startGoal('create-identity')
|
||||
expect(store.getGoalStatus('create-identity')).toBe('in-progress')
|
||||
})
|
||||
|
||||
it('recognizes app aliases (immich-server matches immich)', () => {
|
||||
mockPackages['immich-server'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.startGoal('store-photos')
|
||||
store.completeStep('store-photos', 'configure-immich')
|
||||
expect(store.getGoalStatus('store-photos')).toBe('completed')
|
||||
})
|
||||
|
||||
it('auto-syncs install steps from actual package state', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'stopped' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.getGoalStatus('accept-payments')
|
||||
|
||||
// Should have auto-created progress and marked install-bitcoin as completed
|
||||
expect(store.progress['accept-payments']).toBeDefined()
|
||||
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
|
||||
})
|
||||
})
|
||||
|
||||
it('goalStatuses computes status for all goals', () => {
|
||||
mockPackages['bitcoin-knots'] = { state: 'running' }
|
||||
mockPackages['lnd'] = { state: 'running' }
|
||||
|
||||
const store = useGoalStore()
|
||||
store.startGoal('accept-payments')
|
||||
store.completeStep('accept-payments', 'open-channel')
|
||||
const statuses = store.goalStatuses
|
||||
|
||||
expect(statuses['accept-payments']).toBe('completed')
|
||||
expect(statuses['create-identity']).toBe('not-started')
|
||||
expect(statuses['store-photos']).toBe('not-started')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
vpnStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useHomeStatusStore } from '../homeStatus'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
function pkg(state: string): Record<string, PackageDataEntry> {
|
||||
return { 'bitcoin-knots': { state } as unknown as PackageDataEntry }
|
||||
}
|
||||
|
||||
describe('homeStatus — B16 bitcoin sync status retain (no vanish, no stale-as-live)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('records a successful poll as available + not stale', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
expect(store.stats.bitcoinSyncPercent).toBe(100)
|
||||
expect(store.bitcoinStale).toBe(false)
|
||||
expect(store.bitcoinLoadState).toBe('ready')
|
||||
})
|
||||
|
||||
it('keeps the tile visible (available) but marks stale when getinfo fails while the container is Running', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
// First a good poll so we have real sync numbers.
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 0.5 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
// Now a transient RPC failure (e.g. RPC busy during heavy IBD) — container still Running.
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // does NOT vanish
|
||||
expect(store.bitcoinStale).toBe(true) // shown as "Updating…", not live
|
||||
expect(store.stats.bitcoinSyncPercent).toBe(50) // last-known retained
|
||||
})
|
||||
|
||||
it('flips to NOT available (and not stale) when getinfo fails and the container is Stopped — no stale-as-live', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('refused'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Stopped))
|
||||
expect(store.stats.bitcoinAvailable).toBe(false) // genuinely down → reflect it
|
||||
expect(store.bitcoinStale).toBe(false) // not "Updating…": it's authoritatively stopped
|
||||
})
|
||||
|
||||
it('retains the last-known available value (marked stale) when package data is momentarily absent', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
// getinfo fails AND the packages map has no authoritative bitcoin entry (route change / scan).
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // retained, does NOT flash "Not running"
|
||||
expect(store.bitcoinStale).toBe(true)
|
||||
expect(store.bitcoinLoadState).toBe('ready')
|
||||
})
|
||||
|
||||
it('stays unknown (null) without fabricating availability when the first ever poll fails with no package data', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBeNull() // nothing known yet — don't invent a tile
|
||||
expect(store.bitcoinLoadState).toBe('error')
|
||||
})
|
||||
|
||||
it('marks bitcoin available + stale when the first poll times out but the container is Running (syncing node)', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
// No prior success; getinfo times out during heavy initial sync, but container is up.
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // tile appears instead of staying hidden
|
||||
expect(store.bitcoinStale).toBe(true) // labeled "Updating…" since we have no live numbers yet
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useLoginTransitionStore } from '../loginTransition'
|
||||
|
||||
describe('useLoginTransitionStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with all flags false', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
expect(store.justLoggedIn).toBe(false)
|
||||
expect(store.justCompletedOnboarding).toBe(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(false)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('setJustCompletedOnboarding updates justCompletedOnboarding', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustCompletedOnboarding(true)
|
||||
expect(store.justCompletedOnboarding).toBe(true)
|
||||
store.setJustCompletedOnboarding(false)
|
||||
expect(store.justCompletedOnboarding).toBe(false)
|
||||
})
|
||||
|
||||
it('setJustLoggedIn updates justLoggedIn', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustLoggedIn(true)
|
||||
expect(store.justLoggedIn).toBe(true)
|
||||
store.setJustLoggedIn(false)
|
||||
expect(store.justLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('setPendingWelcomeTyping updates pendingWelcomeTyping', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setPendingWelcomeTyping(true)
|
||||
expect(store.pendingWelcomeTyping).toBe(true)
|
||||
store.setPendingWelcomeTyping(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('setStartWelcomeTyping updates startWelcomeTyping', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setStartWelcomeTyping(true)
|
||||
expect(store.startWelcomeTyping).toBe(true)
|
||||
store.setStartWelcomeTyping(false)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
})
|
||||
|
||||
it('flags are independent of each other', () => {
|
||||
const store = useLoginTransitionStore()
|
||||
store.setJustLoggedIn(true)
|
||||
store.setPendingWelcomeTyping(true)
|
||||
expect(store.startWelcomeTyping).toBe(false)
|
||||
|
||||
store.setStartWelcomeTyping(true)
|
||||
store.setJustLoggedIn(false)
|
||||
expect(store.pendingWelcomeTyping).toBe(true)
|
||||
expect(store.startWelcomeTyping).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useResourcesStore } from '../resources'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
|
||||
describe('resources store — stale-while-revalidate semantics', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('first fetch goes idle → loading → ready with data', async () => {
|
||||
const store = useResourcesStore()
|
||||
const e = store.entry<string>('k1')
|
||||
expect(e.loadState).toBe('idle')
|
||||
const p = store.refresh('k1', async () => 'hello')
|
||||
expect(e.loadState).toBe('loading')
|
||||
await p
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toBe('hello')
|
||||
expect(e.fetchedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('sticky-ready: refresh never regresses ready → loading', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k2', async () => 1)
|
||||
const e = store.entry<number>('k2')
|
||||
const p = store.refresh('k2', async () => 2)
|
||||
expect(e.loadState).toBe('refreshing')
|
||||
await p
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps last-known data on refresh error (ready + error set)', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k3', async () => 'good')
|
||||
const e = store.entry<string>('k3')
|
||||
await store.refresh('k3', async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
expect(e.data).toBe('good')
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.error).toBe('boom')
|
||||
})
|
||||
|
||||
it('errors with no prior data land in error state', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k4', async () => {
|
||||
throw new Error('down')
|
||||
})
|
||||
const e = store.entry('k4')
|
||||
expect(e.loadState).toBe('error')
|
||||
expect(e.data).toBeNull()
|
||||
})
|
||||
|
||||
it('dedups concurrent refreshes for the same key', async () => {
|
||||
const store = useResourcesStore()
|
||||
const fetcher = vi.fn(async () => 'once')
|
||||
const p1 = store.refresh('k5', fetcher)
|
||||
const p2 = store.refresh('k5', fetcher)
|
||||
await Promise.all([p1, p2])
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates a new entry from the sessionStorage snapshot', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k6', async () => ({ n: 42 }))
|
||||
// Fresh pinia = fresh memory cache, same sessionStorage.
|
||||
setActivePinia(createPinia())
|
||||
const store2 = useResourcesStore()
|
||||
const e = store2.entry<{ n: number }>('k6')
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toEqual({ n: 42 })
|
||||
})
|
||||
|
||||
it('optimistic update applies immediately and rollback restores', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k7', async () => ['a'])
|
||||
const e = store.entry<string[]>('k7')
|
||||
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
|
||||
expect(e.data).toEqual(['a', 'b'])
|
||||
rollback()
|
||||
expect(e.data).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('invalidate marks stale and debounce-runs subscribers', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k8', async () => 1)
|
||||
const revalidate = vi.fn()
|
||||
store.subscribe('k8', revalidate)
|
||||
store.invalidate('k8')
|
||||
expect(store.entry('k8').fetchedAt).toBeNull()
|
||||
expect(revalidate).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(900)
|
||||
expect(revalidate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCachedResource composable', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('fetches immediately when stale and exposes reactive views', async () => {
|
||||
const fetcher = vi.fn(async () => 'data')
|
||||
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
|
||||
await r.refresh()
|
||||
expect(fetcher).toHaveBeenCalled()
|
||||
expect(r.data.value).toBe('data')
|
||||
expect(r.loadState.value).toBe('ready')
|
||||
expect(r.isStale.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refetch within TTL (instant render from cache)', async () => {
|
||||
const fetcher = vi.fn(async () => 'v1')
|
||||
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
|
||||
await r1.refresh()
|
||||
// Second component using the same key inside the TTL: no new fetch.
|
||||
const fetcher2 = vi.fn(async () => 'v2')
|
||||
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
|
||||
expect(r2.data.value).toBe('v1')
|
||||
expect(fetcher2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useScreensaverStore } from '../screensaver'
|
||||
|
||||
describe('useScreensaverStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts inactive', () => {
|
||||
const store = useScreensaverStore()
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('activate sets isActive to true', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.activate()
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('deactivate sets isActive to false', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.activate()
|
||||
store.deactivate()
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('deactivate starts inactivity timer that activates after 3 minutes', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('resetInactivityTimer restarts the 3-minute countdown', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
|
||||
// Advance 2 minutes
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
// Reset timer
|
||||
store.resetInactivityTimer()
|
||||
|
||||
// Advance another 2 minutes (would have triggered without reset)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
// Full 3 minutes from reset
|
||||
vi.advanceTimersByTime(1 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('clearInactivityTimer prevents activation', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
store.clearInactivityTimer()
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('suppression prevents automatic and manual activation until resumed', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.suppress('video')
|
||||
expect(store.isSuppressed).toBe(true)
|
||||
|
||||
store.resetInactivityTimer()
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
store.activate()
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
store.resume('video')
|
||||
expect(store.isSuppressed).toBe(false)
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('activate clears any pending timer', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
store.activate()
|
||||
|
||||
// If timer wasn't cleared, deactivating and waiting would trigger twice
|
||||
store.deactivate()
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the nav sounds module
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useSpotlightStore } from '../spotlight'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const mockedPlayNavSound = vi.mocked(playNavSound)
|
||||
|
||||
describe('useSpotlightStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('starts closed with default state', () => {
|
||||
const store = useSpotlightStore()
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('open sets isOpen to true and plays sound', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.open()
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
|
||||
})
|
||||
|
||||
it('close sets isOpen to false and resets index', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
store.close()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.selectedIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('toggle opens when closed', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.toggle()
|
||||
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle closes when open', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
store.toggle()
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('setSelectedIndex updates the selected index', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.setSelectedIndex(3)
|
||||
|
||||
expect(store.selectedIndex).toBe(3)
|
||||
})
|
||||
|
||||
describe('recent items', () => {
|
||||
it('addRecentItem adds item with timestamp', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
expect(store.recentItems[0]!.id).toBe('home')
|
||||
expect(store.recentItems[0]!.label).toBe('Home')
|
||||
expect(store.recentItems[0]!.timestamp).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('addRecentItem deduplicates by id and type', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
|
||||
store.addRecentItem({ id: 'home', label: 'Home Updated', path: '/dashboard', type: 'navigate' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
expect(store.recentItems[0]!.label).toBe('Home Updated')
|
||||
})
|
||||
|
||||
it('addRecentItem keeps different types with same id', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (navigate)', type: 'navigate' })
|
||||
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (action)', type: 'action' })
|
||||
|
||||
expect(store.recentItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('addRecentItem caps at 8 items', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.addRecentItem({ id: `item-${i}`, label: `Item ${i}`, type: 'navigate' })
|
||||
}
|
||||
|
||||
expect(store.recentItems).toHaveLength(8)
|
||||
// Most recent should be first
|
||||
expect(store.recentItems[0]!.id).toBe('item-9')
|
||||
})
|
||||
|
||||
it('addRecentItem persists to localStorage', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.addRecentItem({ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate' })
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('archipelago-spotlight-recent')!)
|
||||
expect(stored).toHaveLength(1)
|
||||
expect(stored[0].id).toBe('apps')
|
||||
})
|
||||
|
||||
it('loadRecentItems reads from localStorage', () => {
|
||||
const saved = [
|
||||
{ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate', timestamp: 1000 },
|
||||
{ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate', timestamp: 2000 },
|
||||
]
|
||||
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toHaveLength(2)
|
||||
expect(store.recentItems[0]!.id).toBe('home')
|
||||
})
|
||||
|
||||
it('loadRecentItems handles corrupt localStorage', () => {
|
||||
localStorage.setItem('archipelago-spotlight-recent', 'not-json{{{')
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('loadRecentItems handles empty localStorage', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.loadRecentItems()
|
||||
|
||||
expect(store.recentItems).toEqual([])
|
||||
})
|
||||
|
||||
it('open calls loadRecentItems', () => {
|
||||
const saved = [
|
||||
{ id: 'test', label: 'Test', type: 'navigate', timestamp: 1000 },
|
||||
]
|
||||
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
|
||||
|
||||
const store = useSpotlightStore()
|
||||
store.open()
|
||||
|
||||
expect(store.recentItems).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('help modal', () => {
|
||||
it('showHelpModal opens the modal with content', () => {
|
||||
const store = useSpotlightStore()
|
||||
|
||||
store.showHelpModal({
|
||||
title: 'What is Bitcoin?',
|
||||
content: 'A peer-to-peer electronic cash system.',
|
||||
relatedPath: '/apps/bitcoin',
|
||||
})
|
||||
|
||||
expect(store.helpModal.show).toBe(true)
|
||||
expect(store.helpModal.title).toBe('What is Bitcoin?')
|
||||
expect(store.helpModal.content).toBe('A peer-to-peer electronic cash system.')
|
||||
expect(store.helpModal.relatedPath).toBe('/apps/bitcoin')
|
||||
})
|
||||
|
||||
it('closeHelpModal closes the modal', () => {
|
||||
const store = useSpotlightStore()
|
||||
store.showHelpModal({ title: 'Test', content: 'Content' })
|
||||
|
||||
store.closeHelpModal()
|
||||
|
||||
expect(store.helpModal.show).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUIModeStore } from '../uiMode'
|
||||
|
||||
describe('useUIModeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults to gamer mode when no stored value', () => {
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('gamer')
|
||||
expect(store.isGamer).toBe(true)
|
||||
expect(store.isEasy).toBe(false)
|
||||
expect(store.isChat).toBe(false)
|
||||
})
|
||||
|
||||
it('loads stored mode from localStorage', () => {
|
||||
localStorage.setItem('archipelago-ui-mode', 'easy')
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(store.isEasy).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores invalid localStorage values', () => {
|
||||
localStorage.setItem('archipelago-ui-mode', 'invalid-mode')
|
||||
const store = useUIModeStore()
|
||||
expect(store.mode).toBe('gamer')
|
||||
})
|
||||
|
||||
it('setMode updates mode and persists', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(store.isEasy).toBe(true)
|
||||
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
|
||||
})
|
||||
|
||||
it('setMode to chat mode', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('chat')
|
||||
expect(store.mode).toBe('chat')
|
||||
expect(store.isChat).toBe(true)
|
||||
expect(store.isGamer).toBe(false)
|
||||
})
|
||||
|
||||
it('cycleMode cycles between easy and gamer', () => {
|
||||
const store = useUIModeStore()
|
||||
// Start at gamer
|
||||
expect(store.mode).toBe('gamer')
|
||||
|
||||
// Cycle to easy
|
||||
const next1 = store.cycleMode()
|
||||
expect(next1).toBe('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
|
||||
// Cycle back to gamer (wraps after easy since order is [easy, gamer])
|
||||
const next2 = store.cycleMode()
|
||||
expect(next2).toBe('gamer')
|
||||
expect(store.mode).toBe('gamer')
|
||||
})
|
||||
|
||||
it('cycleMode from chat wraps to easy', () => {
|
||||
const store = useUIModeStore()
|
||||
store.setMode('chat')
|
||||
const next = store.cycleMode()
|
||||
// chat is not in the order array, so idx=-1, next = order[0] = easy
|
||||
expect(next).toBe('easy')
|
||||
})
|
||||
|
||||
it('syncFromBackend updates mode from backend', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend('easy')
|
||||
expect(store.mode).toBe('easy')
|
||||
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
|
||||
})
|
||||
|
||||
it('syncFromBackend ignores invalid modes', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend('invalid' as 'gamer')
|
||||
expect(store.mode).toBe('gamer') // unchanged
|
||||
})
|
||||
|
||||
it('syncFromBackend ignores undefined', () => {
|
||||
const store = useUIModeStore()
|
||||
store.syncFromBackend(undefined)
|
||||
expect(store.mode).toBe('gamer') // unchanged
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useWeb5BadgeStore } from '../web5Badge'
|
||||
|
||||
// Mock rpcClient
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
describe('useWeb5BadgeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with zero pending requests', () => {
|
||||
const store = useWeb5BadgeStore()
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh updates count from API', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
requests: [{ id: '1' }, { id: '2' }, { id: '3' }],
|
||||
})
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(3)
|
||||
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'network.list-requests' })
|
||||
})
|
||||
|
||||
it('refresh handles empty requests', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [] })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh handles null requests gracefully', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: null })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
|
||||
expect(store.pendingRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refresh handles API error gracefully', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
store.pendingRequestCount = 5 // pre-existing value
|
||||
await store.refresh()
|
||||
|
||||
// Should not throw, count stays at pre-existing value (error swallowed)
|
||||
expect(store.pendingRequestCount).toBe(5)
|
||||
})
|
||||
|
||||
it('refresh updates count on subsequent calls', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({ requests: [{ id: '1' }] })
|
||||
.mockResolvedValueOnce({ requests: [{ id: '1' }, { id: '2' }] })
|
||||
|
||||
const store = useWeb5BadgeStore()
|
||||
await store.refresh()
|
||||
expect(store.pendingRequestCount).toBe(1)
|
||||
|
||||
await store.refresh()
|
||||
expect(store.pendingRequestCount).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AIContextCategory } from '@/types/aiui-protocol'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ai-permissions'
|
||||
|
||||
export interface AIPermissionCategory {
|
||||
id: AIContextCategory
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export const AI_PERMISSION_CATEGORIES: AIPermissionCategory[] = [
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Installed Apps',
|
||||
description: 'App names, status, and health — no credentials or config details',
|
||||
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'System Stats',
|
||||
description: 'CPU, RAM, disk usage — no file paths or IP addresses',
|
||||
icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network Status',
|
||||
description: 'Connection status, peer count — no IP addresses or keys',
|
||||
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin',
|
||||
label: 'Bitcoin Node',
|
||||
description: 'Block height, sync progress, mempool stats — no wallet keys',
|
||||
icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
group: 'Node Data',
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
label: 'Media Libraries',
|
||||
description: 'Local media libraries — film, music, podcast titles and metadata, no file paths',
|
||||
icon: 'M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z',
|
||||
group: 'Media & Files',
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'File Names',
|
||||
description: 'Folder and file names in Cloud — no file contents',
|
||||
icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||
group: 'Media & Files',
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Documents & Notes',
|
||||
description: 'Document and note titles — no contents',
|
||||
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
group: 'Media & Files',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Web Search',
|
||||
description: 'Web search via your private SearXNG instance',
|
||||
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
group: 'AI & Search',
|
||||
},
|
||||
{
|
||||
id: 'ai-local',
|
||||
label: 'Local AI Models',
|
||||
description: 'Local AI models via Ollama — model names and availability',
|
||||
icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
group: 'AI & Search',
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
label: 'Wallet Overview',
|
||||
description: 'Balance, channel count — no private keys, seeds, or addresses',
|
||||
icon: 'M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
group: 'Financial',
|
||||
},
|
||||
]
|
||||
|
||||
export const useAIPermissionsStore = defineStore('aiPermissions', () => {
|
||||
const enabled = ref<Set<AIContextCategory>>(loadFromStorage())
|
||||
|
||||
function loadFromStorage(): Set<AIContextCategory> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!Array.isArray(parsed)) return new Set()
|
||||
return new Set(parsed.filter((c: unknown) => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)) as AIContextCategory[])
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e)
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
|
||||
function save() {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled.value])) } catch { /* localStorage full or unavailable */ }
|
||||
}
|
||||
|
||||
function isEnabled(category: AIContextCategory): boolean {
|
||||
return enabled.value.has(category)
|
||||
}
|
||||
|
||||
function toggle(category: AIContextCategory) {
|
||||
if (enabled.value.has(category)) {
|
||||
enabled.value.delete(category)
|
||||
} else {
|
||||
enabled.value.add(category)
|
||||
}
|
||||
// Trigger reactivity
|
||||
enabled.value = new Set(enabled.value)
|
||||
save()
|
||||
}
|
||||
|
||||
function enableAll() {
|
||||
enabled.value = new Set(AI_PERMISSION_CATEGORIES.map(c => c.id))
|
||||
save()
|
||||
}
|
||||
|
||||
function disableAll() {
|
||||
enabled.value = new Set()
|
||||
save()
|
||||
}
|
||||
|
||||
const enabledCategories = computed(() => [...enabled.value])
|
||||
const allEnabled = computed(() => enabled.value.size === AI_PERMISSION_CATEGORIES.length)
|
||||
const noneEnabled = computed(() => enabled.value.size === 0)
|
||||
|
||||
return {
|
||||
enabled,
|
||||
isEnabled,
|
||||
toggle,
|
||||
enableAll,
|
||||
disableAll,
|
||||
enabledCategories,
|
||||
allEnabled,
|
||||
noneEnabled,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// Facade store — re-exports auth, sync, and server stores for backward compatibility.
|
||||
// All 29+ files that import useAppStore() continue to work without changes.
|
||||
// Uses defineStore with computed/writableComputed to preserve full reactivity.
|
||||
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { useAuthStore } from './auth'
|
||||
import { useSyncStore } from './sync'
|
||||
import { useServerStore } from './server'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const auth = useAuthStore()
|
||||
const sync = useSyncStore()
|
||||
const server = useServerStore()
|
||||
|
||||
// Writable refs — delegate reads and writes to the sub-stores
|
||||
const { isAuthenticated, isLoading, error } = storeToRefs(auth)
|
||||
const { data, isConnected, isReconnecting, hasLoadedInitialData } = storeToRefs(sync)
|
||||
|
||||
// Read-only computed — delegate to sub-stores
|
||||
const { serverInfo, packages, peerHealth, uiData } = storeToRefs(sync)
|
||||
const { serverName, isRestarting, isShuttingDown, isOffline } = storeToRefs(server)
|
||||
|
||||
return {
|
||||
// Auth state (writable refs)
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Sync state (writable refs)
|
||||
data,
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
hasLoadedInitialData,
|
||||
|
||||
// Sync computed (read-only)
|
||||
serverInfo,
|
||||
packages,
|
||||
peerHealth,
|
||||
uiData,
|
||||
|
||||
// Server computed (read-only)
|
||||
serverName,
|
||||
isRestarting,
|
||||
isShuttingDown,
|
||||
isOffline,
|
||||
|
||||
// Auth actions
|
||||
login: auth.login,
|
||||
completeLoginAfterTotp: auth.completeLoginAfterTotp,
|
||||
logout: auth.logout,
|
||||
checkSession: auth.checkSession,
|
||||
needsSessionValidation: auth.needsSessionValidation,
|
||||
|
||||
// Sync actions
|
||||
connectWebSocket: sync.connectWebSocket,
|
||||
|
||||
// Server actions
|
||||
installPackage: server.installPackage,
|
||||
uninstallPackage: server.uninstallPackage,
|
||||
startPackage: server.startPackage,
|
||||
stopPackage: server.stopPackage,
|
||||
restartPackage: server.restartPackage,
|
||||
updatePackage: server.updatePackage,
|
||||
updateServer: server.updateServer,
|
||||
restartServer: server.restartServer,
|
||||
shutdownServer: server.shutdownServer,
|
||||
getMetrics: server.getMetrics,
|
||||
getMarketplace: server.getMarketplace,
|
||||
updateServerName: server.updateServerName,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,540 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
/**
|
||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||
* driving this kiosk, hand the URL to the phone instead so it opens in the
|
||||
* phone's browser rather than the (often headless / unattended) kiosk display.
|
||||
* Falls back to a local `window.open` when no companion is active.
|
||||
*/
|
||||
function openExternal(launchUrl: string) {
|
||||
// Resolve to an absolute URL so the phone can open it (window.open also
|
||||
// handles absolute URLs fine).
|
||||
let absolute = launchUrl
|
||||
try {
|
||||
absolute = new URL(launchUrl, window.location.origin).href
|
||||
} catch {
|
||||
/* keep as-is */
|
||||
}
|
||||
if (requestExternalOpen(absolute)) return
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||
const NEW_TAB_PORTS = new Set([
|
||||
'23000', // BTCPay — X-Frame-Options: DENY
|
||||
'3000', // Grafana — X-Frame-Options: deny
|
||||
'2342', // PhotoPrism — X-Frame-Options: DENY
|
||||
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
|
||||
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
'3002', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
'9001', // Penpot — not reachable
|
||||
// Port 7777 is the Nostr relay; IndeeHub's web UI is exposed on 7778.
|
||||
])
|
||||
|
||||
const NEW_TAB_APP_IDS = new Set([
|
||||
'btcpay-server',
|
||||
'grafana',
|
||||
'photoprism',
|
||||
'homeassistant',
|
||||
'vaultwarden',
|
||||
'nextcloud',
|
||||
'portainer',
|
||||
'tailscale',
|
||||
'nginx-proxy-manager',
|
||||
'uptime-kuma',
|
||||
'gitea',
|
||||
// netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
|
||||
// PKCE), so it's served over HTTPS and must open in a real tab — a
|
||||
// self-signed-HTTPS iframe is blocked by the browser (you can't accept the
|
||||
// cert warning inside an iframe).
|
||||
'netbird',
|
||||
])
|
||||
|
||||
// Apps served over HTTPS (self-signed) rather than plain HTTP.
|
||||
const HTTPS_APP_IDS = new Set(['netbird'])
|
||||
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return NEW_TAB_PORTS.has(u.port)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isMobileViewport(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||
}
|
||||
|
||||
function inferAppIdFromTitle(title?: string): string | null {
|
||||
const t = (title || '').toLowerCase()
|
||||
if (!t) return null
|
||||
if (t.includes('indeehub') || t.includes('indeedhub')) return 'indeedhub'
|
||||
if ((t.includes('uptime') && t.includes('kuma')) || t.includes('uptime-kuma')) return 'uptime-kuma'
|
||||
if ((t.includes('nginx') && t.includes('proxy') && t.includes('manager')) || t.includes('nginx-proxy-manager')) return 'nginx-proxy-manager'
|
||||
if (t.includes('gitea')) return 'gitea'
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
let rewrittenLocalhost = false
|
||||
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
|
||||
u.hostname = window.location.hostname
|
||||
rewrittenLocalhost = true
|
||||
}
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const normalizedPath = u.pathname === '/' ? '' : u.pathname
|
||||
const rebuilt = (port: string) => `${u.protocol}//${u.hostname}:${port}${normalizedPath}${u.search}${u.hash}`
|
||||
|
||||
if (sameHost && appIdHint === 'indeedhub' && u.port === '7777') {
|
||||
return rebuilt('7778')
|
||||
}
|
||||
|
||||
if (sameHost && appIdHint === 'uptime-kuma' && u.port === '3001') {
|
||||
return rebuilt('3002')
|
||||
}
|
||||
|
||||
if (sameHost && appIdHint === 'nginx-proxy-manager' && (u.port === '81' || u.port === '8181')) {
|
||||
return rebuilt('8081')
|
||||
}
|
||||
|
||||
return rewrittenLocalhost ? u.toString() : urlStr
|
||||
} catch {
|
||||
return urlStr
|
||||
}
|
||||
}
|
||||
|
||||
/** Port → app ID for resolving URLs to AppSession routes */
|
||||
const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'81': 'nginx-proxy-manager',
|
||||
'8081': 'nginx-proxy-manager',
|
||||
'8181': 'nginx-proxy-manager',
|
||||
'3000': 'grafana',
|
||||
'3002': 'uptime-kuma',
|
||||
'8080': 'endurain',
|
||||
'18083': 'lnd',
|
||||
'8082': 'vaultwarden',
|
||||
'8083': 'filebrowser',
|
||||
'8085': 'nextcloud',
|
||||
'8096': 'jellyfin',
|
||||
'8123': 'homeassistant',
|
||||
'8240': 'tailscale',
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'8087': 'netbird',
|
||||
'8086': 'netbird',
|
||||
'11434': 'ollama',
|
||||
'2283': 'immich',
|
||||
'23000': 'btcpay-server',
|
||||
'2342': 'photoprism',
|
||||
'4080': 'mempool',
|
||||
'8175': 'fedimint',
|
||||
'8176': 'fedimint-gateway',
|
||||
'7778': 'indeedhub',
|
||||
'50002': 'electrumx',
|
||||
}
|
||||
|
||||
const APP_ID_TO_PORT: Record<string, string> = {
|
||||
'btcpay-server': '23000',
|
||||
grafana: '3000',
|
||||
photoprism: '2342',
|
||||
homeassistant: '8123',
|
||||
vaultwarden: '8082',
|
||||
nextcloud: '8085',
|
||||
portainer: '9000',
|
||||
tailscale: '8240',
|
||||
'nginx-proxy-manager': '8081',
|
||||
'uptime-kuma': '3002',
|
||||
gitea: '3001',
|
||||
// Without this, directAppUrl('netbird') returns null and netbird falls
|
||||
// through to the iframe (and never gets its https URL) — issue #15.
|
||||
netbird: '8087',
|
||||
}
|
||||
|
||||
function directAppUrl(appId: string): string | null {
|
||||
const port = APP_ID_TO_PORT[appId]
|
||||
if (!port || typeof window === 'undefined') return null
|
||||
const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http'
|
||||
return `${scheme}://${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
|
||||
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
||||
|
||||
function getApprovedOrigins(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
|
||||
if (!stored) return new Set()
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!Array.isArray(parsed)) return new Set()
|
||||
return new Set(parsed.filter((s: unknown) => typeof s === 'string'))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveApprovedOrigin(origin: string) {
|
||||
const origins = getApprovedOrigins()
|
||||
origins.add(origin)
|
||||
try { localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins])) } catch { /* localStorage full or unavailable */ }
|
||||
}
|
||||
|
||||
export interface NostrConsentRequest {
|
||||
appName: string
|
||||
method: string
|
||||
eventKind?: number
|
||||
content?: string
|
||||
resolve: (remember: boolean) => void
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
const title = ref('')
|
||||
const consentRequest = ref<NostrConsentRequest | null>(null)
|
||||
const showConsent = ref(false)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
/** Active app in the store-driven session (no route change) */
|
||||
const panelAppId = ref<string | null>(null)
|
||||
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
|
||||
const panelPath = ref<string | null>(null)
|
||||
|
||||
function openSession(appId: string, opts: { path?: string } = {}) {
|
||||
recordAppLaunch(appId)
|
||||
const mobile = isMobileViewport()
|
||||
|
||||
// Companion app: EVERY app opens in the native in-app WebView — never an
|
||||
// iframe. The WebView is more performant on the phone and carries the
|
||||
// native back/forward/reload/close controls. Plain mobile browsers (PWA)
|
||||
// keep the iframe session below.
|
||||
if (!IS_DEMO && isCompanionApp()) {
|
||||
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
||||
if (launchUrl) {
|
||||
openInAppOrNewTab(launchUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Demo: apps backed by a real external site that blocks iframing open
|
||||
// externally; everything else demoable renders in the in-app session.
|
||||
if (IS_DEMO && isDemoExternal(appId)) {
|
||||
const ext = demoAppUrl(appId)
|
||||
if (ext) {
|
||||
if (mobile) openInAppOrNewTab(ext)
|
||||
else openExternal(ext)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
|
||||
// desktop opens a new browser tab; mobile opens the in-app WebView (Android
|
||||
// companion) or a new browser tab (PWA) — see openInAppOrNewTab.
|
||||
// In the demo, demoable apps are served same-origin by the mock backend
|
||||
// (no framing headers), so they always render in the in-app session.
|
||||
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
|
||||
const launchUrl = directAppUrl(appId)
|
||||
if (launchUrl) {
|
||||
if (mobile) openInAppOrNewTab(launchUrl)
|
||||
else openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Iframeable apps always use the store-driven session so the underlying
|
||||
// page never changes: panel mode renders beside the page, overlay and
|
||||
// fullscreen modes render above it (AppSession styles per display mode).
|
||||
// Closing always returns the user exactly where they launched from.
|
||||
panelPath.value = opts.path ?? null
|
||||
panelAppId.value = appId
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelAppId.value = null
|
||||
panelPath.value = null
|
||||
}
|
||||
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||
const titleHintId = inferAppIdFromTitle(payload.title)
|
||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
|
||||
// its OIDC dashboard) must be launched over https — a stale http URL hits
|
||||
// the TLS port and 400s. Upgrade the scheme defensively in every path.
|
||||
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
|
||||
try {
|
||||
const u = new URL(launchUrl, window.location.origin)
|
||||
if (u.protocol === 'http:') {
|
||||
u.protocol = 'https:'
|
||||
launchUrl = u.href
|
||||
}
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
|
||||
if (!isMobileViewport() && payload.openInNewTab) {
|
||||
if (resolvedId) recordAppLaunch(resolvedId)
|
||||
openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Force selected apps to open directly in new tab on desktop only. On
|
||||
// phones, route through the app session/webview so app icons behave like
|
||||
// native launchers and keep the user inside Archipelago.
|
||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
recordAppLaunch(resolvedId)
|
||||
openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Open the in-app session if we can resolve an app ID from the URL,
|
||||
// carrying through any deep-link path (e.g. /tx/<hash>).
|
||||
if (resolvedId) {
|
||||
let deepPath: string | undefined
|
||||
try {
|
||||
const u = new URL(launchUrl, window.location.origin)
|
||||
let p = `${u.pathname}${u.search}${u.hash}`
|
||||
// /app/<id>/-style URLs carry the mount prefix — strip it so only the
|
||||
// app-internal path is treated as the deep link.
|
||||
const prefix = `/app/${resolvedId}`
|
||||
if (p.startsWith(prefix)) p = p.slice(prefix.length) || '/'
|
||||
if (p && p !== '/') deepPath = p
|
||||
} catch { /* no deep link */ }
|
||||
openSession(resolvedId, { path: deepPath })
|
||||
return
|
||||
}
|
||||
|
||||
// Unknown apps that block iframes — open directly in new tab
|
||||
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
|
||||
openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Companion app: never fall through to the iframe overlay — hand the URL
|
||||
// to the native in-app WebView instead (see openSession).
|
||||
if (!IS_DEMO && isCompanionApp()) {
|
||||
openInAppOrNewTab(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||
url.value = launchUrl
|
||||
title.value = payload.title
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
/** Resolve an app ID from a URL (port or known external) */
|
||||
function resolveAppIdFromUrl(urlStr: string): string | null {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
|
||||
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
|
||||
if (m?.[1]) return m[1].toLowerCase()
|
||||
// Check port-based apps
|
||||
const appId = PORT_TO_APP_ID[u.port]
|
||||
if (appId) return appId
|
||||
// Check external URLs
|
||||
const EXTERNAL_APP_HOSTS: Record<string, string> = {
|
||||
'botfights.net': 'botfights',
|
||||
'nostrudel.ninja': 'nostrudel',
|
||||
}
|
||||
return EXTERNAL_APP_HOSTS[u.hostname] || null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function close() {
|
||||
const toRestore = previousActiveElement
|
||||
previousActiveElement = null
|
||||
isOpen.value = false
|
||||
url.value = ''
|
||||
title.value = ''
|
||||
// Explicitly remove NIP-07 listener as safety net — if user navigates away
|
||||
// without close() triggering the isOpen watcher, the listener would leak
|
||||
window.removeEventListener('message', handleNostrRequest)
|
||||
if (toRestore && typeof toRestore.focus === 'function') {
|
||||
requestAnimationFrame(() => {
|
||||
toRestore.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function approveConsent(remember: boolean) {
|
||||
if (consentRequest.value) {
|
||||
consentRequest.value.resolve(remember)
|
||||
consentRequest.value = null
|
||||
}
|
||||
showConsent.value = false
|
||||
}
|
||||
|
||||
function denyConsent() {
|
||||
if (consentRequest.value) {
|
||||
consentRequest.value.reject()
|
||||
consentRequest.value = null
|
||||
}
|
||||
showConsent.value = false
|
||||
}
|
||||
|
||||
function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
consentRequest.value = { appName, method, eventKind, content, resolve, reject }
|
||||
showConsent.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// NIP-07 postMessage handler — responds to nostr-request from iframe apps
|
||||
async function handleNostrRequest(event: MessageEvent) {
|
||||
if (!event.data || event.data.type !== 'nostr-request') return
|
||||
const { id, method, params } = event.data
|
||||
const source = event.source as Window | null
|
||||
if (!source) return
|
||||
|
||||
// Only the app we actually opened may drive this bridge. The sender's
|
||||
// real origin must match the open app's URL origin — without this, any
|
||||
// co-resident iframe could deanonymize the nostr identity or use the
|
||||
// node as a decryption oracle while an app happened to be open.
|
||||
let expectedOrigin: string
|
||||
try {
|
||||
expectedOrigin = new URL(url.value, window.location.href).origin
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (event.origin !== expectedOrigin) return
|
||||
|
||||
const origin = url.value || 'unknown'
|
||||
|
||||
// Check if app has a per-app identity stored (from identity picker)
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_')
|
||||
let appIdentityId: string | null = null
|
||||
try {
|
||||
const stored = localStorage.getItem(appKey)
|
||||
if (stored) {
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) {
|
||||
const idVal = (parsed as Record<string, unknown>).id
|
||||
appIdentityId = typeof idVal === 'string' ? idVal : null
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Every identity-sensitive method needs consent (or a remembered
|
||||
// approval for this origin) — not just signEvent. getPublicKey
|
||||
// deanonymizes; the decrypts turn the node into a decryption oracle.
|
||||
const CONSENT_METHODS = new Set([
|
||||
'getPublicKey', 'signEvent',
|
||||
'nip04.encrypt', 'nip04.decrypt',
|
||||
'nip44.encrypt', 'nip44.decrypt',
|
||||
])
|
||||
if (CONSENT_METHODS.has(method) && !getApprovedOrigins().has(origin)) {
|
||||
const eventKind = method === 'signEvent' ? (params?.event?.kind as number | undefined) : undefined
|
||||
const content = method === 'signEvent' ? (params?.event?.content as string | undefined) : undefined
|
||||
try {
|
||||
const remember = await requestConsent(title.value || 'App', method, eventKind, content)
|
||||
if (remember) saveApprovedOrigin(origin)
|
||||
} catch {
|
||||
source.postMessage({ type: 'nostr-response', id, error: `User denied ${method} request` }, origin || '*')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
|
||||
if (method === 'getPublicKey') {
|
||||
if (appIdentityId) {
|
||||
// Use the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({
|
||||
method: 'identity.get', params: { id: appIdentityId }
|
||||
})
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
if (appIdentityId) {
|
||||
// Sign with the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<unknown>({
|
||||
method: 'identity.nostr-sign',
|
||||
params: { id: appIdentityId, event: params.event }
|
||||
})
|
||||
result = res
|
||||
} else {
|
||||
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
result = res
|
||||
}
|
||||
} else if (method === 'getRelays') {
|
||||
result = {}
|
||||
} else if (method === 'nip04.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip04',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip04.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip04',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else if (method === 'nip44.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip44',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip44.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip44',
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else {
|
||||
throw new Error(`Unsupported NIP-07 method: ${method}`)
|
||||
}
|
||||
source.postMessage({ type: 'nostr-response', id, result }, origin || '*')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*')
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for NIP-07 requests only while an app is open
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
window.addEventListener('message', handleNostrRequest)
|
||||
} else {
|
||||
window.removeEventListener('message', handleNostrRequest)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
url,
|
||||
title,
|
||||
open,
|
||||
openSession,
|
||||
close,
|
||||
closePanel,
|
||||
panelAppId,
|
||||
panelPath,
|
||||
showConsent,
|
||||
consentRequest,
|
||||
approveConsent,
|
||||
denyConsent,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
// Authentication store — login, logout, session management
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useSyncStore } from './sync'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let sessionValidated = false
|
||||
|
||||
// Actions
|
||||
async function login(password: string): Promise<{ requires_totp?: boolean }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await rpcClient.login(password)
|
||||
if (result && result.requires_totp) {
|
||||
return { requires_totp: true }
|
||||
}
|
||||
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
|
||||
|
||||
const sync = useSyncStore()
|
||||
|
||||
// Initialize data structure immediately so dashboard can render
|
||||
await sync.initializeData()
|
||||
|
||||
// Verify session cookies are established before WebSocket connect.
|
||||
// Without this, the WS upgrade can race ahead of cookie processing → 401.
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } })
|
||||
} catch {
|
||||
// Non-fatal: WS reconnect logic will handle it
|
||||
}
|
||||
|
||||
// Connect WebSocket in background
|
||||
sync.connectWebSocket().catch((err) => {
|
||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err)
|
||||
})
|
||||
return {}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeLoginAfterTotp(): Promise<void> {
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
|
||||
|
||||
const sync = useSyncStore()
|
||||
await sync.initializeData()
|
||||
|
||||
// Verify session cookies are established before WebSocket connect
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } })
|
||||
} catch {
|
||||
// Non-fatal: WS reconnect logic will handle it
|
||||
}
|
||||
|
||||
sync.connectWebSocket().catch((err) => {
|
||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
||||
})
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
const sync = useSyncStore()
|
||||
try {
|
||||
await rpcClient.logout()
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Logout error:', err)
|
||||
} finally {
|
||||
isAuthenticated.value = false
|
||||
sessionValidated = false
|
||||
localStorage.removeItem('neode-auth')
|
||||
sync.resetOnLogout()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSession(): Promise<boolean> {
|
||||
if (!localStorage.getItem('neode-auth')) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
|
||||
const sync = useSyncStore()
|
||||
await sync.initializeData()
|
||||
|
||||
sync.connectWebSocket().catch((err) => {
|
||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Store] Session check failed:', err)
|
||||
localStorage.removeItem('neode-auth')
|
||||
isAuthenticated.value = false
|
||||
sessionValidated = false
|
||||
|
||||
const sync = useSyncStore()
|
||||
sync.resetOnLogout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function needsSessionValidation(): boolean {
|
||||
return isAuthenticated.value && !sessionValidated
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
completeLoginAfterTotp,
|
||||
logout,
|
||||
checkSession,
|
||||
needsSessionValidation,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
export const useCLIStore = defineStore('cli', () => {
|
||||
const isOpen = ref(false)
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
playNavSound('action')
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const wasOpen = isOpen.value
|
||||
isOpen.value = !wasOpen
|
||||
if (!wasOpen) playNavSound('action')
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
||||
|
||||
export const useCloudStore = defineStore('cloud', () => {
|
||||
const currentPath = ref('/')
|
||||
const items = ref<FileBrowserItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const authenticated = ref(false)
|
||||
// Per-path listing cache: re-entering a folder paints the last listing
|
||||
// immediately (no spinner) while the fresh listing loads behind it.
|
||||
const pathCache = new Map<string, FileBrowserItem[]>()
|
||||
// Last-wins guard for overlapping navigations (fast folder hopping).
|
||||
let navSeq = 0
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
const crumbs = [{ name: 'Home', path: '/' }]
|
||||
let path = ''
|
||||
for (const part of parts) {
|
||||
path += `/${part}`
|
||||
crumbs.push({ name: part, path })
|
||||
}
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const dirs = items.value.filter((i) => i.isDir)
|
||||
const files = items.value.filter((i) => !i.isDir)
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
||||
files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return [...dirs, ...files]
|
||||
})
|
||||
|
||||
async function init(): Promise<boolean> {
|
||||
if (authenticated.value) return true
|
||||
const ok = await fileBrowserClient.login()
|
||||
authenticated.value = ok
|
||||
return ok
|
||||
}
|
||||
|
||||
async function navigate(path: string): Promise<void> {
|
||||
const seq = ++navSeq
|
||||
const apply = (p: string, result: FileBrowserItem[]) => {
|
||||
pathCache.set(p, result)
|
||||
if (seq !== navSeq) return // a newer navigation superseded this one
|
||||
items.value = result
|
||||
currentPath.value = p
|
||||
}
|
||||
// Stale-while-revalidate: show the cached listing for this path
|
||||
// immediately (no spinner), then refresh it underneath.
|
||||
const cached = pathCache.get(path)
|
||||
if (cached) {
|
||||
items.value = cached
|
||||
currentPath.value = path
|
||||
} else {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = null
|
||||
try {
|
||||
if (!authenticated.value) {
|
||||
const ok = await init()
|
||||
if (!ok) {
|
||||
error.value = 'Failed to authenticate with File Browser'
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
apply(path, await fileBrowserClient.listDirectory(path))
|
||||
} catch {
|
||||
// Directory may not exist — try to create it, then retry
|
||||
if (path !== '/') {
|
||||
try {
|
||||
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
||||
const dirName = path.substring(path.lastIndexOf('/') + 1)
|
||||
await fileBrowserClient.createFolder(parentPath, dirName)
|
||||
apply(path, await fileBrowserClient.listDirectory(path))
|
||||
} catch {
|
||||
// Fall back to root
|
||||
apply('/', await fileBrowserClient.listDirectory('/'))
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to list root directory')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep showing the cached listing on a failed revalidate.
|
||||
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
|
||||
} finally {
|
||||
if (seq === navSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await navigate(currentPath.value)
|
||||
}
|
||||
|
||||
async function uploadFile(file: File): Promise<void> {
|
||||
await fileBrowserClient.upload(currentPath.value, file)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function deleteItem(path: string): Promise<void> {
|
||||
await fileBrowserClient.deleteItem(path)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function downloadUrl(path: string): string {
|
||||
return fileBrowserClient.downloadUrl(path)
|
||||
}
|
||||
|
||||
async function fetchBlobUrl(path: string): Promise<string> {
|
||||
return fileBrowserClient.fetchBlobUrl(path)
|
||||
}
|
||||
|
||||
async function streamUrl(path: string): Promise<string> {
|
||||
return fileBrowserClient.streamUrl(path)
|
||||
}
|
||||
|
||||
async function downloadFile(path: string): Promise<void> {
|
||||
return fileBrowserClient.downloadFile(path)
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
currentPath.value = '/'
|
||||
items.value = []
|
||||
loading.value = false
|
||||
error.value = null
|
||||
pathCache.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
authenticated,
|
||||
breadcrumbs,
|
||||
sortedItems,
|
||||
init,
|
||||
navigate,
|
||||
refresh,
|
||||
uploadFile,
|
||||
deleteItem,
|
||||
downloadUrl,
|
||||
fetchBlobUrl,
|
||||
streamUrl,
|
||||
downloadFile,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,369 @@
|
||||
// Pinia store for container management
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { containerClient, type ContainerStatus } from '@/api/container-client'
|
||||
|
||||
// Bundled apps that come pre-loaded with Archipelago
|
||||
export interface BundledApp {
|
||||
id: string
|
||||
name: string
|
||||
image: string
|
||||
description: string
|
||||
icon: string
|
||||
ports: { host: number; container: number }[]
|
||||
volumes: { host: string; container: string }[]
|
||||
category: 'bitcoin' | 'lightning' | 'home' | 'other'
|
||||
lan_address?: string // Runtime launch URL from backend
|
||||
}
|
||||
|
||||
/** Map bundled app ID to the podman container name(s) used for status matching.
|
||||
* Some apps have a different container name than their app ID, or use a
|
||||
* separate UI container (e.g., bitcoin-knots node → bitcoin-ui web container). */
|
||||
const CONTAINER_NAME_MAP: Record<string, string[]> = {
|
||||
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
|
||||
'lnd': ['lnd', 'archy-lnd-ui'],
|
||||
'btcpay-server': ['btcpay-server'],
|
||||
'mempool': ['mempool', 'archy-mempool-web'],
|
||||
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
|
||||
}
|
||||
|
||||
export const BUNDLED_APPS: BundledApp[] = [
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
name: 'Bitcoin Knots',
|
||||
image: '146.59.87.168:3000/lfg2025/bitcoin-knots:latest',
|
||||
description: 'Full Bitcoin node with additional features',
|
||||
icon: '₿',
|
||||
ports: [{ host: 8334, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
name: 'Lightning (LND)',
|
||||
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
|
||||
description: 'Lightning Network Daemon for fast Bitcoin payments',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 18083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
|
||||
category: 'lightning',
|
||||
},
|
||||
{
|
||||
id: 'homeassistant',
|
||||
name: 'Home Assistant',
|
||||
image: 'ghcr.io/home-assistant/home-assistant:stable',
|
||||
description: 'Open source home automation platform',
|
||||
icon: '🏠',
|
||||
ports: [{ host: 8123, container: 8123 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/homeassistant', container: '/config' }],
|
||||
category: 'home',
|
||||
},
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
name: 'BTCPay Server',
|
||||
image: 'docker.io/btcpayserver/btcpayserver:latest',
|
||||
description: 'Self-hosted Bitcoin payment processor',
|
||||
icon: '💳',
|
||||
ports: [{ host: 23000, container: 49392 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'mempool',
|
||||
name: 'Mempool Explorer',
|
||||
image: 'docker.io/mempool/frontend:latest',
|
||||
description: 'Bitcoin blockchain and mempool visualizer',
|
||||
icon: '🔍',
|
||||
ports: [{ host: 4080, container: 8080 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'tailscale',
|
||||
name: 'Tailscale VPN',
|
||||
image: 'docker.io/tailscale/tailscale:latest',
|
||||
description: 'Zero-config VPN mesh network',
|
||||
icon: '🔒',
|
||||
ports: [],
|
||||
volumes: [{ host: '/var/lib/archipelago/tailscale', container: '/var/lib/tailscale' }],
|
||||
category: 'other',
|
||||
},
|
||||
]
|
||||
|
||||
export const useContainerStore = defineStore('container', () => {
|
||||
// State
|
||||
const containers = ref<ContainerStatus[]>([])
|
||||
const healthStatus = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const loadingApps = ref<Set<string>>(new Set()) // Track loading state per app
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const runningContainers = computed(() =>
|
||||
containers.value.filter(c => c.state === 'running')
|
||||
)
|
||||
|
||||
const stoppedContainers = computed(() =>
|
||||
containers.value.filter(c => c.state === 'stopped' || c.state === 'exited')
|
||||
)
|
||||
|
||||
const getContainerById = computed(() => (id: string) =>
|
||||
containers.value.find(c => c.name.includes(id))
|
||||
)
|
||||
|
||||
const getHealthStatus = computed(() => (appId: string) =>
|
||||
healthStatus.value[appId] || 'unknown'
|
||||
)
|
||||
|
||||
// Get container for a bundled app (matches by explicit name map, then by exact name)
|
||||
const getContainerForApp = computed(() => (appId: string) => {
|
||||
const nameList = CONTAINER_NAME_MAP[appId]
|
||||
if (nameList) {
|
||||
// Try each known container name in priority order
|
||||
for (const n of nameList) {
|
||||
const found = containers.value.find(c => c.name === n)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
// Fallback: exact match on app ID
|
||||
return containers.value.find(c => c.name === appId)
|
||||
})
|
||||
|
||||
// Check if an app is currently loading (starting/stopping)
|
||||
const isAppLoading = computed(() => (appId: string) =>
|
||||
loadingApps.value.has(appId)
|
||||
)
|
||||
|
||||
// Get app state: 'running', 'stopped', 'not-installed'
|
||||
const getAppState = computed(() => (appId: string) => {
|
||||
const container = getContainerForApp.value(appId)
|
||||
if (!container) return 'not-installed'
|
||||
return container.state
|
||||
})
|
||||
|
||||
// Get visual state for UI — collapses backend states into the small set the
|
||||
// single-button component needs. Transitional states (stopping/starting/
|
||||
// restarting/installing/updating/removing) pass through; resting states
|
||||
// (exited/created/paused/installed) collapse to 'stopped' or 'running' so
|
||||
// the button logic can stay simple.
|
||||
type VisualState =
|
||||
| 'not-installed'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
| 'stopping'
|
||||
| 'starting'
|
||||
| 'restarting'
|
||||
| 'installing'
|
||||
| 'updating'
|
||||
| 'removing'
|
||||
| 'unknown'
|
||||
const getAppVisualState = computed(() => (appId: string): VisualState => {
|
||||
const container = getContainerForApp.value(appId)
|
||||
if (!container) return 'not-installed'
|
||||
switch (container.state) {
|
||||
case 'running':
|
||||
return 'running'
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
case 'created':
|
||||
case 'paused':
|
||||
case 'installed':
|
||||
return 'stopped'
|
||||
case 'stopping':
|
||||
case 'starting':
|
||||
case 'restarting':
|
||||
case 'installing':
|
||||
case 'updating':
|
||||
case 'removing':
|
||||
return container.state
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
})
|
||||
|
||||
// Get enriched bundled apps with runtime data (like lan_address)
|
||||
const enrichedBundledApps = computed(() => {
|
||||
return BUNDLED_APPS.map(app => {
|
||||
const container = getContainerForApp.value(app.id)
|
||||
return {
|
||||
...app,
|
||||
lan_address: container?.lan_address
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function fetchContainers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
containers.value = await containerClient.listContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to fetch containers'
|
||||
if (import.meta.env.DEV) console.error('Failed to fetch containers:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHealthStatus() {
|
||||
try {
|
||||
healthStatus.value = await containerClient.getHealthStatus()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to fetch health status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function installApp(manifestPath: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const containerName = await containerClient.installApp(manifestPath)
|
||||
await fetchContainers()
|
||||
return containerName
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to install app'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startContainer(appId: string) {
|
||||
loadingApps.value.add(appId)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.startContainer(appId)
|
||||
await fetchContainers()
|
||||
await fetchHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start container'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopContainer(appId: string) {
|
||||
loadingApps.value.add(appId)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.stopContainer(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop container'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
async function restartContainer(appId: string) {
|
||||
loadingApps.value.add(appId)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.restartContainer(appId)
|
||||
await fetchContainers()
|
||||
await fetchHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to restart container'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
// Start a bundled app (creates and starts container)
|
||||
async function startBundledApp(app: BundledApp) {
|
||||
loadingApps.value.add(app.id)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.startBundledApp(app)
|
||||
await fetchContainers()
|
||||
await fetchHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start app'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(app.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop a bundled app
|
||||
async function stopBundledApp(appId: string) {
|
||||
loadingApps.value.add(appId)
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.stopBundledApp(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop app'
|
||||
throw e
|
||||
} finally {
|
||||
loadingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeContainer(appId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await containerClient.removeContainer(appId)
|
||||
await fetchContainers()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove container'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerLogs(appId: string, lines: number = 100) {
|
||||
try {
|
||||
return await containerClient.getContainerLogs(appId, lines)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to get logs'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerStatus(appId: string) {
|
||||
try {
|
||||
return await containerClient.getContainerStatus(appId)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to get status'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
containers,
|
||||
healthStatus,
|
||||
loading,
|
||||
loadingApps,
|
||||
error,
|
||||
// Getters
|
||||
runningContainers,
|
||||
stoppedContainers,
|
||||
getContainerById,
|
||||
getHealthStatus,
|
||||
getContainerForApp,
|
||||
isAppLoading,
|
||||
getAppState,
|
||||
getAppVisualState,
|
||||
enrichedBundledApps,
|
||||
// Actions
|
||||
fetchContainers,
|
||||
fetchHealthStatus,
|
||||
installApp,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
restartContainer,
|
||||
removeContainer,
|
||||
getContainerLogs,
|
||||
getContainerStatus,
|
||||
startBundledApp,
|
||||
stopBundledApp,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useControllerStore = defineStore('controller', () => {
|
||||
const isActive = ref(false)
|
||||
const gamepadCount = ref(0)
|
||||
|
||||
function setActive(active: boolean) {
|
||||
isActive.value = active
|
||||
}
|
||||
|
||||
function setGamepadCount(count: number) {
|
||||
gamepadCount.value = count
|
||||
isActive.value = count > 0
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
gamepadCount,
|
||||
setActive,
|
||||
setGamepadCount,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { GoalProgress, GoalStatus } from '@/types/goals'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useAppStore } from './app'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-goal-progress'
|
||||
|
||||
/** App ID aliases — goal definitions use canonical IDs but the backend may register under variant names */
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
|
||||
}
|
||||
|
||||
/** Pre-built reverse lookup: variant pkgId → Set of canonical appIds it matches */
|
||||
const ALIAS_REVERSE = new Map<string, Set<string>>()
|
||||
for (const [canonical, aliases] of Object.entries(APP_ALIASES)) {
|
||||
for (const alias of aliases) {
|
||||
let set = ALIAS_REVERSE.get(alias)
|
||||
if (!set) { set = new Set(); ALIAS_REVERSE.set(alias, set) }
|
||||
set.add(canonical)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesAppId(pkgId: string, appId: string): boolean {
|
||||
if (pkgId === appId) return true
|
||||
const reverseHits = ALIAS_REVERSE.get(pkgId)
|
||||
return reverseHits ? reverseHits.has(appId) : false
|
||||
}
|
||||
|
||||
export const useGoalStore = defineStore('goals', () => {
|
||||
const progress = ref<Record<string, GoalProgress>>({})
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) progress.value = JSON.parse(raw)
|
||||
} catch {
|
||||
/* ignore corrupt data */
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(progress.value)) } catch { /* localStorage full or unavailable */ }
|
||||
}
|
||||
|
||||
function getGoalStatus(goalId: string): GoalStatus {
|
||||
const goal = GOALS.find((g) => g.id === goalId)
|
||||
if (!goal) return 'not-started'
|
||||
|
||||
// Goals with no required apps use manual progress tracking
|
||||
if (goal.requiredApps.length === 0) {
|
||||
return progress.value[goalId]?.status || 'not-started'
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
const packages = appStore.packages
|
||||
|
||||
// Auto-sync install step completion from actual package state
|
||||
// This ensures steps tick when apps are installed outside the wizard
|
||||
let didSync = false
|
||||
for (const step of goal.steps) {
|
||||
if (step.appId && step.action === 'install') {
|
||||
const isInstalled = Object.keys(packages).some((pkgId) => matchesAppId(pkgId, step.appId!))
|
||||
if (isInstalled) {
|
||||
if (!progress.value[goalId]) {
|
||||
progress.value[goalId] = {
|
||||
goalId,
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 0,
|
||||
completedSteps: [],
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
didSync = true
|
||||
}
|
||||
if (!progress.value[goalId].completedSteps.includes(step.id)) {
|
||||
progress.value[goalId].completedSteps.push(step.id)
|
||||
didSync = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (didSync) save()
|
||||
|
||||
const allRunning = goal.requiredApps.every((appId) =>
|
||||
Object.entries(packages).some(
|
||||
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
|
||||
),
|
||||
)
|
||||
|
||||
// Manual steps (fund the wallet, open a channel, configure the store…)
|
||||
// must be walked through too — running apps alone don't finish a goal.
|
||||
const done = progress.value[goalId]?.completedSteps ?? []
|
||||
const allManualDone = goal.steps
|
||||
.filter((s) => s.action !== 'install')
|
||||
.every((s) => done.includes(s.id))
|
||||
if (allRunning && allManualDone) return 'completed'
|
||||
|
||||
const anyInstalled = goal.requiredApps.some((appId) =>
|
||||
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
|
||||
)
|
||||
if (allRunning || anyInstalled || progress.value[goalId]) return 'in-progress'
|
||||
|
||||
return 'not-started'
|
||||
}
|
||||
|
||||
const goalStatuses = computed(() => {
|
||||
const statuses: Record<string, GoalStatus> = {}
|
||||
for (const goal of GOALS) {
|
||||
statuses[goal.id] = getGoalStatus(goal.id)
|
||||
}
|
||||
return statuses
|
||||
})
|
||||
|
||||
function startGoal(goalId: string) {
|
||||
progress.value[goalId] = {
|
||||
goalId,
|
||||
status: 'in-progress',
|
||||
currentStepIndex: 0,
|
||||
completedSteps: [],
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
function completeStep(goalId: string, stepId: string) {
|
||||
const p = progress.value[goalId]
|
||||
if (!p) return
|
||||
|
||||
if (!p.completedSteps.includes(stepId)) {
|
||||
p.completedSteps.push(stepId)
|
||||
}
|
||||
|
||||
const goal = GOALS.find((g) => g.id === goalId)
|
||||
if (goal && p.completedSteps.length >= goal.steps.length) {
|
||||
p.status = 'completed'
|
||||
} else {
|
||||
p.currentStepIndex = Math.min(p.currentStepIndex + 1, (goal?.steps.length ?? 1) - 1)
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
function resetGoal(goalId: string) {
|
||||
delete progress.value[goalId]
|
||||
save()
|
||||
}
|
||||
|
||||
// Load on store creation
|
||||
load()
|
||||
|
||||
return {
|
||||
progress,
|
||||
goalStatuses,
|
||||
getGoalStatus,
|
||||
startGoal,
|
||||
completeStep,
|
||||
resetGoal,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
interface SystemStatsSnapshot {
|
||||
cpuPercent: number
|
||||
memUsed: number
|
||||
memTotal: number
|
||||
memPercent: number
|
||||
diskUsed: number
|
||||
diskTotal: number
|
||||
diskPercent: number
|
||||
uptimeSecs: number
|
||||
loadAvg1: number
|
||||
loadAvg5: number
|
||||
loadAvg15: number
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
bitcoinAvailable: boolean | null
|
||||
}
|
||||
|
||||
const emptyStats = (): SystemStatsSnapshot => ({
|
||||
cpuPercent: 0,
|
||||
memUsed: 0,
|
||||
memTotal: 0,
|
||||
memPercent: 0,
|
||||
diskUsed: 0,
|
||||
diskTotal: 0,
|
||||
diskPercent: 0,
|
||||
uptimeSecs: 0,
|
||||
loadAvg1: 0,
|
||||
loadAvg5: 0,
|
||||
loadAvg15: 0,
|
||||
bitcoinSyncPercent: 0,
|
||||
bitcoinBlockHeight: 0,
|
||||
bitcoinAvailable: null,
|
||||
})
|
||||
|
||||
export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
const stats = reactive<SystemStatsSnapshot>(emptyStats())
|
||||
const systemLoadState = ref<LoadState>('idle')
|
||||
const bitcoinLoadState = ref<LoadState>('idle')
|
||||
// True when we're showing a retained (last-known) bitcoin value because the
|
||||
// latest poll failed transiently — the UI renders an "Updating…" badge so the
|
||||
// figure is never presented as live, and the tile never vanishes mid-sync.
|
||||
const bitcoinStale = ref(false)
|
||||
const vpnLoadState = ref<LoadState>('idle')
|
||||
const fipsLoadState = ref<LoadState>('idle')
|
||||
const tollgateLoadState = ref<LoadState>('idle')
|
||||
const lastSystemRefreshAt = ref<number | null>(null)
|
||||
const lastBitcoinRefreshAt = ref<number | null>(null)
|
||||
const lastVpnRefreshAt = ref<number | null>(null)
|
||||
const lastFipsRefreshAt = ref<number | null>(null)
|
||||
const lastTollgateRefreshAt = ref<number | null>(null)
|
||||
|
||||
const vpnStatus = ref<{
|
||||
connected: boolean | null
|
||||
provider: string
|
||||
}>({ connected: null, provider: '' })
|
||||
|
||||
const fipsStatus = ref<{
|
||||
installed: boolean
|
||||
service_active: boolean
|
||||
key_present: boolean
|
||||
anchor_connected?: boolean
|
||||
authenticated_peer_count?: number
|
||||
} | null>(null)
|
||||
|
||||
// null = no OpenWrt router configured at all (tile row shows "Not configured").
|
||||
const tollgateStatus = ref<{ installed: boolean; enabled: boolean } | null>(null)
|
||||
|
||||
const systemStatsLoaded = computed(() => systemLoadState.value === 'ready')
|
||||
const bitcoinKnown = computed(() => stats.bitcoinAvailable !== null)
|
||||
const vpnKnown = computed(() => vpnStatus.value.connected !== null)
|
||||
|
||||
async function refreshSystemStats() {
|
||||
systemLoadState.value = systemLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
cpu_usage_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_total_bytes: number
|
||||
disk_used_bytes: number
|
||||
disk_total_bytes: number
|
||||
uptime_secs: number
|
||||
load_avg_1?: number
|
||||
load_avg_5?: number
|
||||
load_avg_15?: number
|
||||
}>({ method: 'system.stats' })
|
||||
stats.cpuPercent = res.cpu_usage_percent
|
||||
stats.memUsed = res.mem_used_bytes
|
||||
stats.memTotal = res.mem_total_bytes
|
||||
stats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0
|
||||
stats.diskUsed = res.disk_used_bytes
|
||||
stats.diskTotal = res.disk_total_bytes
|
||||
stats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0
|
||||
stats.uptimeSecs = res.uptime_secs
|
||||
stats.loadAvg1 = res.load_avg_1 ?? 0
|
||||
stats.loadAvg5 = res.load_avg_5 ?? 0
|
||||
stats.loadAvg15 = res.load_avg_15 ?? 0
|
||||
systemLoadState.value = 'ready'
|
||||
lastSystemRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
systemLoadState.value = stats.uptimeSecs > 0 ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBitcoin(packages: Record<string, PackageDataEntry>) {
|
||||
bitcoinLoadState.value = bitcoinLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 5000,
|
||||
})
|
||||
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
|
||||
stats.bitcoinBlockHeight = btc.block_height ?? 0
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinStale.value = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin
|
||||
if (btcPkg?.state === PackageState.Running) {
|
||||
// Container is up but the RPC call failed (busy during heavy IBD, etc.).
|
||||
// Keep the tile visible with the last-known figures, marked as updating.
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinStale.value = true
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) {
|
||||
// Authoritatively down — reflect it (do NOT keep showing stale data as live).
|
||||
stats.bitcoinAvailable = false
|
||||
bitcoinStale.value = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
// No authoritative package data yet. Keep the previous known value
|
||||
// rather than flashing "Not running" during route changes/scans; if we
|
||||
// had a value, surface it as "updating" instead of presenting it as live.
|
||||
if (stats.bitcoinAvailable !== null) bitcoinStale.value = true
|
||||
bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshVpn(packages: Record<string, PackageDataEntry>) {
|
||||
vpnLoadState.value = vpnLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const status = await rpcClient.vpnStatus()
|
||||
vpnStatus.value = {
|
||||
connected: status.connected,
|
||||
provider: status.provider ?? status.configured_provider ?? '',
|
||||
}
|
||||
vpnLoadState.value = 'ready'
|
||||
lastVpnRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const tailscale = packages.tailscale
|
||||
if (tailscale?.state === PackageState.Running) {
|
||||
vpnStatus.value = { connected: true, provider: 'tailscale' }
|
||||
vpnLoadState.value = 'ready'
|
||||
lastVpnRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
vpnLoadState.value = vpnStatus.value.connected === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFips() {
|
||||
fipsLoadState.value = fipsLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const status = await rpcClient.call<{
|
||||
installed: boolean
|
||||
service_active: boolean
|
||||
key_present: boolean
|
||||
anchor_connected?: boolean
|
||||
authenticated_peer_count?: number
|
||||
}>({ method: 'fips.status' })
|
||||
fipsStatus.value = status
|
||||
fipsLoadState.value = 'ready'
|
||||
lastFipsRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
fipsLoadState.value = fipsStatus.value ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTollgate() {
|
||||
tollgateLoadState.value = tollgateLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const res = await rpcClient.call<{ tollgate: { installed: boolean; enabled?: boolean } }>({
|
||||
method: 'openwrt.get-status',
|
||||
timeout: 15000,
|
||||
})
|
||||
tollgateStatus.value = { installed: res.tollgate.installed, enabled: res.tollgate.enabled ?? false }
|
||||
tollgateLoadState.value = 'ready'
|
||||
lastTollgateRefreshAt.value = Date.now()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (msg.includes('No router configured')) {
|
||||
// Not an error — most nodes simply don't have an OpenWrt gateway set up.
|
||||
tollgateStatus.value = null
|
||||
tollgateLoadState.value = 'ready'
|
||||
lastTollgateRefreshAt.value = Date.now()
|
||||
} else {
|
||||
// Transient failure (SSH hiccup, router rebooting) — keep last-known state.
|
||||
tollgateLoadState.value = tollgateStatus.value ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(packages: Record<string, PackageDataEntry>) {
|
||||
await Promise.all([
|
||||
refreshSystemStats(),
|
||||
refreshBitcoin(packages),
|
||||
refreshVpn(packages),
|
||||
refreshFips(),
|
||||
refreshTollgate(),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
stats,
|
||||
systemLoadState,
|
||||
bitcoinLoadState,
|
||||
bitcoinStale,
|
||||
vpnLoadState,
|
||||
fipsLoadState,
|
||||
tollgateLoadState,
|
||||
systemStatsLoaded,
|
||||
bitcoinKnown,
|
||||
vpnKnown,
|
||||
vpnStatus,
|
||||
fipsStatus,
|
||||
tollgateStatus,
|
||||
lastSystemRefreshAt,
|
||||
lastBitcoinRefreshAt,
|
||||
lastVpnRefreshAt,
|
||||
lastFipsRefreshAt,
|
||||
lastTollgateRefreshAt,
|
||||
refresh,
|
||||
refreshSystemStats,
|
||||
refreshBitcoin,
|
||||
refreshVpn,
|
||||
refreshFips,
|
||||
refreshTollgate,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
// Install store — tracks in-progress app installations across navigation.
|
||||
// Marketplace.vue writes here; Apps.vue reads to show "Installing..." cards.
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
export interface InstallEntry {
|
||||
id: string
|
||||
title: string
|
||||
status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
|
||||
progress: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const useInstallStore = defineStore('install', () => {
|
||||
// Reactive map: appId -> InstallEntry
|
||||
const entries = reactive(new Map<string, InstallEntry>())
|
||||
|
||||
/** All app IDs currently installing */
|
||||
const installingIds = computed(() => new Set(entries.keys()))
|
||||
|
||||
/** Start tracking an install */
|
||||
function trackInstall(id: string, title: string) {
|
||||
entries.set(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
message: 'Preparing installation...',
|
||||
})
|
||||
}
|
||||
|
||||
/** Update progress for an in-flight install */
|
||||
function updateProgress(id: string, update: Partial<Omit<InstallEntry, 'id'>>) {
|
||||
const current = entries.get(id)
|
||||
if (!current) return
|
||||
entries.set(id, { ...current, ...update })
|
||||
}
|
||||
|
||||
/** Mark install complete and auto-clear after delay */
|
||||
function completeInstall(id: string) {
|
||||
const current = entries.get(id)
|
||||
if (!current) return
|
||||
entries.set(id, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
||||
setTimeout(() => entries.delete(id), 2000)
|
||||
}
|
||||
|
||||
/** Mark install as failed and auto-clear after delay */
|
||||
function failInstall(id: string, message: string) {
|
||||
const current = entries.get(id)
|
||||
if (!current) return
|
||||
entries.set(id, { ...current, status: 'error', progress: 0, message })
|
||||
setTimeout(() => entries.delete(id), 5000)
|
||||
}
|
||||
|
||||
/** Remove tracking (e.g. when backend reports the app is installed) */
|
||||
function clearInstall(id: string) {
|
||||
entries.delete(id)
|
||||
}
|
||||
|
||||
/** Check if an app is currently installing */
|
||||
function isInstalling(id: string): boolean {
|
||||
return entries.has(id)
|
||||
}
|
||||
|
||||
/** Get progress for an app, or undefined */
|
||||
function getProgress(id: string): InstallEntry | undefined {
|
||||
return entries.get(id)
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
installingIds,
|
||||
trackInstall,
|
||||
updateProgress,
|
||||
completeInstall,
|
||||
failInstall,
|
||||
clearInstall,
|
||||
isInstalling,
|
||||
getProgress,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** Signals that we just logged in - Dashboard uses this for zoom + oomph */
|
||||
export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
const justLoggedIn = ref(false)
|
||||
/**
|
||||
* True only when the user just finished the onboarding wizard
|
||||
* (first password setup), as distinct from a regular re-login.
|
||||
* Dashboard uses this to decide whether to play the full glitchy
|
||||
* reveal vs just a quick interface-draw.
|
||||
*/
|
||||
const justCompletedOnboarding = ref(false)
|
||||
/** Show empty welcome block until typing starts (hide static text) */
|
||||
const pendingWelcomeTyping = ref(false)
|
||||
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
|
||||
const startWelcomeTyping = ref(false)
|
||||
/**
|
||||
* True while the dashboard entrance cinematic (zoom/glitch reveal) is
|
||||
* playing. Overlays that would visually compete with it (e.g. the
|
||||
* companion intro popup) hold off until this flips back to false.
|
||||
*/
|
||||
const introCinematicPlaying = ref(false)
|
||||
|
||||
function setJustLoggedIn(value: boolean) {
|
||||
justLoggedIn.value = value
|
||||
}
|
||||
|
||||
function setJustCompletedOnboarding(value: boolean) {
|
||||
justCompletedOnboarding.value = value
|
||||
}
|
||||
|
||||
function setPendingWelcomeTyping(value: boolean) {
|
||||
pendingWelcomeTyping.value = value
|
||||
}
|
||||
|
||||
function setStartWelcomeTyping(value: boolean) {
|
||||
startWelcomeTyping.value = value
|
||||
}
|
||||
|
||||
function setIntroCinematicPlaying(value: boolean) {
|
||||
introCinematicPlaying.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
justLoggedIn,
|
||||
setJustLoggedIn,
|
||||
justCompletedOnboarding,
|
||||
setJustCompletedOnboarding,
|
||||
pendingWelcomeTyping,
|
||||
setPendingWelcomeTyping,
|
||||
startWelcomeTyping,
|
||||
setStartWelcomeTyping,
|
||||
introCinematicPlaying,
|
||||
setIntroCinematicPlaying,
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
|
||||
//
|
||||
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
|
||||
// keys its router-view by route.path, so each visit unmounted and refetched
|
||||
// everything. This store is the single place resource state lives instead:
|
||||
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
|
||||
// snapshot), and `useCachedResource` renders them instantly while
|
||||
// revalidating in the background.
|
||||
//
|
||||
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
|
||||
// hand-rolled versions):
|
||||
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
|
||||
// refreshes show as 'refreshing' so the UI keeps the data visible.
|
||||
// - keep-last-known-value on error: a failed revalidate leaves data in place
|
||||
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
|
||||
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
|
||||
|
||||
export interface ResourceEntry<T = unknown> {
|
||||
data: T | null
|
||||
loadState: ResourceLoadState
|
||||
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
|
||||
fetchedAt: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const SNAPSHOT_PREFIX = 'resource:'
|
||||
|
||||
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
|
||||
} catch {
|
||||
/* corrupt/absent snapshot — fall through to a fresh fetch */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
|
||||
} catch {
|
||||
/* quota exceeded or unserializable — memory cache still works */
|
||||
}
|
||||
}
|
||||
|
||||
export const useResourcesStore = defineStore('resources', () => {
|
||||
const entries = reactive(new Map<string, ResourceEntry>())
|
||||
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const revalidators = new Map<string, Set<() => void>>()
|
||||
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/** Get (or create) the reactive entry for a key, hydrating from the
|
||||
* sessionStorage snapshot on first sight so revisits after a reload paint
|
||||
* before any RPC completes. Pass `persist: false` to skip snapshots. */
|
||||
function entry<T>(key: string, persist = true): ResourceEntry<T> {
|
||||
let e = entries.get(key)
|
||||
if (!e) {
|
||||
const snap = persist ? readSnapshot<T>(key) : null
|
||||
e = reactive<ResourceEntry>({
|
||||
data: snap ? snap.data : null,
|
||||
loadState: snap ? 'ready' : 'idle',
|
||||
fetchedAt: snap ? snap.fetchedAt : null,
|
||||
error: null,
|
||||
})
|
||||
entries.set(key, e)
|
||||
}
|
||||
return e as ResourceEntry<T>
|
||||
}
|
||||
|
||||
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
|
||||
* Concurrent calls for the same key share one in-flight fetch. */
|
||||
function refresh<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
opts: { persist?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const existing = inflight.get(key)
|
||||
if (existing) return existing
|
||||
const e = entry<T>(key, opts.persist ?? true)
|
||||
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
|
||||
const p = (async () => {
|
||||
try {
|
||||
const data = await fetcher()
|
||||
e.data = data
|
||||
e.error = null
|
||||
e.fetchedAt = Date.now()
|
||||
e.loadState = 'ready'
|
||||
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
|
||||
} catch (err) {
|
||||
e.error = err instanceof Error ? err.message : String(err)
|
||||
// Keep last-known data visible; only 'error' when we have nothing.
|
||||
e.loadState = e.data !== null ? 'ready' : 'error'
|
||||
} finally {
|
||||
inflight.delete(key)
|
||||
}
|
||||
})()
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Mark a key stale and (debounced) re-run every mounted subscriber's
|
||||
* fetcher. Call after a mutation or on a relevant WS push. */
|
||||
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
|
||||
const e = entries.get(key)
|
||||
if (e) e.fetchedAt = null
|
||||
const subs = revalidators.get(key)
|
||||
if (!subs || subs.size === 0) return
|
||||
const t = invalidateTimers.get(key)
|
||||
if (t) clearTimeout(t)
|
||||
invalidateTimers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
invalidateTimers.delete(key)
|
||||
for (const fn of subs) fn()
|
||||
}, opts.debounceMs ?? 800),
|
||||
)
|
||||
}
|
||||
|
||||
/** Register a live revalidator for a key (used by useCachedResource);
|
||||
* returns an unsubscribe fn. */
|
||||
function subscribe(key: string, revalidate: () => void): () => void {
|
||||
let subs = revalidators.get(key)
|
||||
if (!subs) {
|
||||
subs = new Set()
|
||||
revalidators.set(key, subs)
|
||||
}
|
||||
subs.add(revalidate)
|
||||
return () => {
|
||||
subs.delete(revalidate)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistically apply `update` to the cached value; returns a rollback.
|
||||
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
|
||||
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
|
||||
const e = entry<T>(key)
|
||||
const before = e.data
|
||||
const beforeState = e.loadState
|
||||
e.data = update(before)
|
||||
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
|
||||
return () => {
|
||||
e.data = before
|
||||
e.loadState = beforeState
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a key entirely (memory + snapshot). */
|
||||
function evict(key: string): void {
|
||||
entries.delete(key)
|
||||
try {
|
||||
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes
|
||||
|
||||
export const useScreensaverStore = defineStore('screensaver', () => {
|
||||
const isActive = ref(false)
|
||||
const activationCount = ref(0)
|
||||
const suppressionReasons = ref<Set<string>>(new Set())
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** True when the current activation is the ASCII variant (every 3rd time) */
|
||||
const isAsciiMode = computed(() => activationCount.value > 0 && activationCount.value % 3 === 0)
|
||||
const isSuppressed = computed(() => suppressionReasons.value.size > 0)
|
||||
|
||||
function activate() {
|
||||
if (isSuppressed.value) return
|
||||
activationCount.value++
|
||||
isActive.value = true
|
||||
clearInactivityTimer()
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
isActive.value = false
|
||||
resetInactivityTimer()
|
||||
}
|
||||
|
||||
function resetInactivityTimer() {
|
||||
clearInactivityTimer()
|
||||
if (isSuppressed.value) return
|
||||
inactivityTimer = setTimeout(() => {
|
||||
inactivityTimer = null
|
||||
if (isSuppressed.value) return
|
||||
isActive.value = true
|
||||
}, INACTIVITY_MS)
|
||||
}
|
||||
|
||||
function clearInactivityTimer() {
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer)
|
||||
inactivityTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function suppress(reason: string) {
|
||||
suppressionReasons.value = new Set(suppressionReasons.value).add(reason)
|
||||
clearInactivityTimer()
|
||||
isActive.value = false
|
||||
}
|
||||
|
||||
function resume(reason: string) {
|
||||
if (!suppressionReasons.value.has(reason)) return
|
||||
const next = new Set(suppressionReasons.value)
|
||||
next.delete(reason)
|
||||
suppressionReasons.value = next
|
||||
if (next.size === 0) resetInactivityTimer()
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isAsciiMode,
|
||||
isSuppressed,
|
||||
activationCount,
|
||||
activate,
|
||||
deactivate,
|
||||
resetInactivityTimer,
|
||||
clearInactivityTimer,
|
||||
suppress,
|
||||
resume,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
// Server store — computed server state and RPC action proxies
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useSyncStore } from './sync'
|
||||
import type { InstallProgress } from '../views/marketplace/marketplaceData'
|
||||
import type { InstallPhase } from '../types/api'
|
||||
|
||||
/**
|
||||
* Phase-to-UI mapping. Each backend pipeline phase maps to a fixed
|
||||
* progress percentage (so the bar only ever advances forward) and a
|
||||
* descriptive label the user can actually understand. This is the
|
||||
* source of truth — byte counters from `install-progress.size/downloaded`
|
||||
* are a fallback for the rare cases where podman does emit parseable
|
||||
* progress on a piped stderr.
|
||||
*
|
||||
* Percentages chosen so:
|
||||
* - the bar is never fully empty (users panic)
|
||||
* - the bar visibly advances at every phase boundary
|
||||
* - the slowest phases (PullingImage, WaitingHealthy) get the widest
|
||||
* bands so shimmer/indeterminate treatment has room
|
||||
* - 100% is reserved for "Done" / terminal success
|
||||
*/
|
||||
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
|
||||
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
|
||||
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
||||
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
||||
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
|
||||
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
|
||||
'post-install': { progress: 95, message: 'Finalizing…', status: 'installing' },
|
||||
'done': { progress: 100, message: 'Installed', status: 'complete' },
|
||||
}
|
||||
|
||||
export const useServerStore = defineStore('server', () => {
|
||||
const sync = useSyncStore()
|
||||
|
||||
// Global install/uninstall tracking — persists across navigation
|
||||
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
||||
const uninstallingApps = ref<Set<string>>(new Set())
|
||||
|
||||
// Watch WebSocket data for real install progress — runs globally, not just on Marketplace page
|
||||
watch(() => sync.packages, (packages) => {
|
||||
if (!packages) return
|
||||
for (const [appId, pkg] of Object.entries(packages)) {
|
||||
if ((pkg.state as string) === 'installing' || (pkg.state as string) === 'updating') {
|
||||
// Backend confirms it's installing — update or create tracking entry
|
||||
if (!installingApps.value.has(appId)) {
|
||||
installingApps.value.set(appId, {
|
||||
id: appId,
|
||||
title: pkg.manifest?.title || appId,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
message: 'Installing…',
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
const progress = pkg['install-progress']
|
||||
if (progress) {
|
||||
const current = installingApps.value.get(appId)!
|
||||
// Primary source: the pipeline phase. Each phase maps to a
|
||||
// fixed progress% and a user-facing label.
|
||||
if (progress.phase) {
|
||||
const info = PHASE_INFO[progress.phase]
|
||||
if (info) {
|
||||
// Within the PullingImage band (20→70%), interpolate the
|
||||
// bar based on how many images / bytes have landed so far.
|
||||
// Without this, multi-container stacks (indeedhub: 7,
|
||||
// mempool: 3, btcpay: 4) just sit at 20% for the entire
|
||||
// pull duration — exactly what the user reported as
|
||||
// "Downloading sticks at 20% mostly". X-of-N progress
|
||||
// comes from set_install_progress(i, n) in stacks.rs.
|
||||
let bandProgress = info.progress
|
||||
if (progress.phase === 'pulling-image' && progress.size > 0) {
|
||||
const fraction = Math.min(progress.downloaded / progress.size, 1)
|
||||
// PullingImage band: 20% → 70%, so 50pp to interpolate over.
|
||||
bandProgress = 20 + Math.round(fraction * 50)
|
||||
}
|
||||
// Only advance forward — never let the bar step backward
|
||||
// between patches (can happen briefly during scan merges).
|
||||
const nextProgress = Math.max(current.progress, bandProgress)
|
||||
// Show explicit message when set (e.g. install-fail descriptions
|
||||
// surfaced via install_progress.message) — otherwise PHASE_INFO label.
|
||||
const label = progress.message || info.message
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
status: info.status,
|
||||
progress: nextProgress,
|
||||
message: label,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
// No phase but message is set (install-fail path) — show the message
|
||||
// even if PHASE_INFO doesn't apply. Status stays whatever the watcher
|
||||
// currently has.
|
||||
if (progress.message) {
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
message: progress.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Fallback: byte counters (rare — podman usually doesn't
|
||||
// emit parseable progress on a piped stderr).
|
||||
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
||||
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
||||
let message = 'Downloading…'
|
||||
if (progress.size > 1024 && pct < 100) {
|
||||
message = `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)`
|
||||
} else if (pct >= 100 || (progress.size > 0 && progress.downloaded >= progress.size)) {
|
||||
message = 'Installing package…'
|
||||
}
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
status: pct >= 100 ? 'installing' : 'downloading',
|
||||
progress: Math.max(current.progress, Math.min(pct, 95)),
|
||||
message,
|
||||
})
|
||||
}
|
||||
} else if (installingApps.value.has(appId)) {
|
||||
const state = pkg.state as string
|
||||
// Only clear when app is fully running or definitively stopped — not during 'starting' transition
|
||||
if (state === 'running' || state === 'stopped' || state === 'exited') {
|
||||
installingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
if ((pkg.state as string) === 'removing') {
|
||||
uninstallingApps.value.add(appId)
|
||||
} else if (uninstallingApps.value.has(appId)) {
|
||||
uninstallingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
// Clear installingApps entries for apps that vanished from backend data
|
||||
// Only clean up entries that have errored — active installs may take minutes to pull images
|
||||
for (const [appId] of installingApps.value) {
|
||||
if (packages && !(appId in packages)) {
|
||||
const entry = installingApps.value.get(appId)
|
||||
if (entry && entry.status === 'error') {
|
||||
installingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear uninstallingApps when the container disappears from backend data
|
||||
for (const appId of uninstallingApps.value) {
|
||||
if (packages && !(appId in packages)) {
|
||||
uninstallingApps.value.delete(appId)
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
||||
const existing = installingApps.value.get(appId)
|
||||
installingApps.value.set(appId, {
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
message: 'Preparing...',
|
||||
attempt: 0,
|
||||
...existing,
|
||||
...progress,
|
||||
})
|
||||
}
|
||||
|
||||
function clearInstallProgress(appId: string) {
|
||||
installingApps.value.delete(appId)
|
||||
}
|
||||
|
||||
function isInstalling(appId: string): boolean {
|
||||
return installingApps.value.has(appId)
|
||||
}
|
||||
|
||||
// Computed — derived from sync store's data
|
||||
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
|
||||
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
|
||||
const isShuttingDown = computed(() => sync.serverInfo?.['status-info']?.['shutting-down'] || false)
|
||||
const isOffline = computed(() => !sync.isConnected || isRestarting.value || isShuttingDown.value)
|
||||
|
||||
// Package actions. install/uninstall/update are async on the backend:
|
||||
// the RPC returns immediately with { status: 'installing'|'removing'|'updating',
|
||||
// package_id } after flipping state, and the real work runs in a spawn.
|
||||
// Progress is streamed via the WebSocket state push, not the RPC response.
|
||||
async function installPackage(
|
||||
id: string,
|
||||
marketplaceUrl: string,
|
||||
version: string,
|
||||
): Promise<{ status: string; package_id: string }> {
|
||||
return rpcClient.installPackage(id, marketplaceUrl, version)
|
||||
}
|
||||
|
||||
async function uninstallPackage(
|
||||
id: string,
|
||||
options: { preserveData?: boolean } = {},
|
||||
): Promise<{ status: string; package_id: string }> {
|
||||
return rpcClient.uninstallPackage(id, options)
|
||||
}
|
||||
|
||||
async function startPackage(id: string): Promise<void> {
|
||||
return rpcClient.startPackage(id)
|
||||
}
|
||||
|
||||
async function stopPackage(id: string): Promise<void> {
|
||||
return rpcClient.stopPackage(id)
|
||||
}
|
||||
|
||||
async function restartPackage(id: string): Promise<void> {
|
||||
return rpcClient.restartPackage(id)
|
||||
}
|
||||
|
||||
async function updatePackage(id: string): Promise<{ status: string; package_id: string }> {
|
||||
return rpcClient.updatePackage(id)
|
||||
}
|
||||
|
||||
// Server actions
|
||||
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
|
||||
return rpcClient.updateServer(marketplaceUrl)
|
||||
}
|
||||
|
||||
async function restartServer(): Promise<void> {
|
||||
return rpcClient.restartServer()
|
||||
}
|
||||
|
||||
async function shutdownServer(): Promise<void> {
|
||||
return rpcClient.shutdownServer()
|
||||
}
|
||||
|
||||
async function getMetrics(): Promise<Record<string, unknown>> {
|
||||
return rpcClient.getMetrics()
|
||||
}
|
||||
|
||||
// Marketplace actions
|
||||
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
|
||||
return rpcClient.getMarketplace(url)
|
||||
}
|
||||
|
||||
function updateServerName(name: string) {
|
||||
if (sync.data?.['server-info']) {
|
||||
sync.data['server-info'].name = name
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Computed
|
||||
serverName,
|
||||
isRestarting,
|
||||
isShuttingDown,
|
||||
isOffline,
|
||||
|
||||
// Install/uninstall tracking (global, persists across navigation)
|
||||
installingApps,
|
||||
setInstallProgress,
|
||||
clearInstallProgress,
|
||||
isInstalling,
|
||||
uninstallingApps,
|
||||
|
||||
// Actions
|
||||
installPackage,
|
||||
uninstallPackage,
|
||||
startPackage,
|
||||
stopPackage,
|
||||
restartPackage,
|
||||
updatePackage,
|
||||
updateServer,
|
||||
restartServer,
|
||||
shutdownServer,
|
||||
getMetrics,
|
||||
getMarketplace,
|
||||
updateServerName,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const RECENT_ITEMS_KEY = 'archipelago-spotlight-recent'
|
||||
const MAX_RECENT_ITEMS = 8
|
||||
|
||||
export interface RecentItem {
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
type: 'navigate' | 'learn' | 'action' | 'goal'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export const useSpotlightStore = defineStore('spotlight', () => {
|
||||
const isOpen = ref(false)
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
const recentItems = ref<RecentItem[]>([])
|
||||
|
||||
function loadRecentItems() {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_ITEMS_KEY)
|
||||
if (raw) {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) {
|
||||
recentItems.value = []
|
||||
return
|
||||
}
|
||||
recentItems.value = parsed
|
||||
.filter((item: unknown) =>
|
||||
typeof item === 'object' && item !== null &&
|
||||
'id' in item && 'label' in item && 'type' in item && 'timestamp' in item
|
||||
)
|
||||
.slice(0, MAX_RECENT_ITEMS) as RecentItem[]
|
||||
} else {
|
||||
recentItems.value = []
|
||||
}
|
||||
} catch {
|
||||
recentItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function addRecentItem(item: Omit<RecentItem, 'timestamp'>) {
|
||||
const withTimestamp: RecentItem = { ...item, timestamp: Date.now() }
|
||||
const filtered = recentItems.value.filter(
|
||||
(r) => !(r.id === item.id && r.type === item.type)
|
||||
)
|
||||
recentItems.value = [withTimestamp, ...filtered].slice(0, MAX_RECENT_ITEMS)
|
||||
try {
|
||||
localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(recentItems.value))
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to save recent items to storage', e)
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
selectedIndex.value = 0
|
||||
loadRecentItems()
|
||||
playNavSound('action')
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen.value ? close() : open()
|
||||
}
|
||||
|
||||
function setSelectedIndex(index: number) {
|
||||
selectedIndex.value = index
|
||||
}
|
||||
|
||||
const helpModal = reactive({
|
||||
show: false,
|
||||
title: '',
|
||||
content: '',
|
||||
relatedPath: undefined as string | undefined,
|
||||
})
|
||||
const helpModalRestoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function showHelpModal(payload: { title: string; content: string; relatedPath?: string }) {
|
||||
helpModalRestoreFocusRef.value = document.activeElement as HTMLElement | null
|
||||
helpModal.show = true
|
||||
helpModal.title = payload.title
|
||||
helpModal.content = payload.content
|
||||
helpModal.relatedPath = payload.relatedPath
|
||||
}
|
||||
|
||||
function closeHelpModal() {
|
||||
helpModalRestoreFocusRef.value?.focus?.()
|
||||
helpModalRestoreFocusRef.value = null
|
||||
helpModal.show = false
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
selectedIndex,
|
||||
recentItems,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setSelectedIndex,
|
||||
addRecentItem,
|
||||
loadRecentItems,
|
||||
helpModal,
|
||||
showHelpModal,
|
||||
closeHelpModal,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
// Sync store — WebSocket connection, real-time data, patch application
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { DataModel, PatchOperation } from '../types/api'
|
||||
import { wsClient, applyDataPatch } from '../api/websocket'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useResourcesStore } from './resources'
|
||||
|
||||
/** Unescape one JSON-pointer segment (RFC 6901: ~1 → '/', ~0 → '~'). */
|
||||
function pointerSegment(path: string, prefix: string): string {
|
||||
const seg = path.slice(prefix.length).split('/')[0] ?? ''
|
||||
return seg.replace(/~1/g, '/').replace(/~0/g, '~')
|
||||
}
|
||||
|
||||
/** B5: bridge /ws/db pushes into the cached-resource layer. Each patch op
|
||||
* maps to the resource keys whose backing data it changes; invalidate()
|
||||
* debounces (800ms) and only refetches keys with mounted subscribers, so a
|
||||
* patch storm costs one revalidation per key. The 30s staleness
|
||||
* reconciliation stays as the backstop for anything unmapped. */
|
||||
function invalidateResourcesForPatch(patch: PatchOperation[]): void {
|
||||
const resources = useResourcesStore()
|
||||
for (const op of patch) {
|
||||
const path = op.path ?? ''
|
||||
if (path.startsWith('/peer-health/')) {
|
||||
// A peer flipping reachability changes both its browse result and the
|
||||
// federation node list's online state.
|
||||
const onion = pointerSegment(path, '/peer-health/')
|
||||
if (onion) resources.invalidate(`cloud.peer-browse:${onion}`)
|
||||
resources.invalidate('federation.nodes')
|
||||
} else if (path.startsWith('/package-data/')) {
|
||||
// App installs/uninstalls add or remove their tor services.
|
||||
resources.invalidate('server.tor-services')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
invalidateResourcesForPatch(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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
// Pinia store for transport layer state (unified routing: mesh > lan > tor)
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export type TransportKind = 'mesh' | 'lan' | 'fips' | 'tor'
|
||||
|
||||
export interface TransportInfo {
|
||||
kind: TransportKind
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export interface TransportStatus {
|
||||
transports: TransportInfo[]
|
||||
mesh_only: boolean
|
||||
peer_count: number
|
||||
}
|
||||
|
||||
export interface TransportPeer {
|
||||
did: string
|
||||
pubkey_hex: string
|
||||
name: string | null
|
||||
trust_level: string | null
|
||||
mesh_contact_id: number | null
|
||||
lan_address: string | null
|
||||
onion_address: string | null
|
||||
preferred_transport: TransportKind
|
||||
available_transports: TransportKind[]
|
||||
last_seen: string | null
|
||||
}
|
||||
|
||||
export const useTransportStore = defineStore('transport', () => {
|
||||
const status = ref<TransportStatus | null>(null)
|
||||
const peers = ref<TransportPeer[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const meshOnly = computed(() => status.value?.mesh_only ?? false)
|
||||
|
||||
const availableTransports = computed(() =>
|
||||
(status.value?.transports ?? []).filter((t) => t.available).map((t) => t.kind)
|
||||
)
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const res = await rpcClient.call<TransportStatus>({ method: 'transport.status' })
|
||||
status.value = res
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch transport status'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPeers() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ peers: TransportPeer[] }>({
|
||||
method: 'transport.peers',
|
||||
})
|
||||
peers.value = res.peers
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch transport peers'
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(did: string, payload: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const res = await rpcClient.call<{ sent: boolean; transport_used: TransportKind }>({
|
||||
method: 'transport.send',
|
||||
params: { did, payload },
|
||||
})
|
||||
return res
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to send via transport'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function setMeshOnly(enabled: boolean) {
|
||||
try {
|
||||
error.value = null
|
||||
await rpcClient.call<{ mesh_only: boolean; configured: boolean }>({
|
||||
method: 'transport.set-mode',
|
||||
params: { mesh_only: enabled },
|
||||
})
|
||||
await fetchStatus()
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to set transport mode'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([fetchStatus(), fetchPeers()])
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
peers,
|
||||
loading,
|
||||
error,
|
||||
meshOnly,
|
||||
availableTransports,
|
||||
fetchStatus,
|
||||
fetchPeers,
|
||||
sendMessage,
|
||||
setMeshOnly,
|
||||
refreshAll,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ui-mode'
|
||||
|
||||
export const useUIModeStore = defineStore('uiMode', () => {
|
||||
const mode = ref<UIMode>(loadFromStorage())
|
||||
|
||||
function loadFromStorage(): UIMode {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'gamer' || stored === 'easy' || stored === 'chat') return stored
|
||||
return 'gamer'
|
||||
}
|
||||
|
||||
function syncFromBackend(backendMode: UIMode | undefined) {
|
||||
if (backendMode && ['gamer', 'easy', 'chat'].includes(backendMode)) {
|
||||
mode.value = backendMode
|
||||
try { localStorage.setItem(STORAGE_KEY, backendMode) } catch { /* localStorage full or unavailable */ }
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(newMode: UIMode) {
|
||||
mode.value = newMode
|
||||
try { localStorage.setItem(STORAGE_KEY, newMode) } catch { /* localStorage full or unavailable */ }
|
||||
}
|
||||
|
||||
function cycleMode(): UIMode {
|
||||
const order: UIMode[] = ['easy', 'gamer']
|
||||
const idx = order.indexOf(mode.value)
|
||||
const next = order[(idx >= 0 ? idx + 1 : 0) % order.length] as UIMode
|
||||
setMode(next)
|
||||
return next
|
||||
}
|
||||
|
||||
const isGamer = computed(() => mode.value === 'gamer')
|
||||
const isEasy = computed(() => mode.value === 'easy')
|
||||
const isChat = computed(() => mode.value === 'chat')
|
||||
|
||||
return { mode, setMode, cycleMode, syncFromBackend, isGamer, isEasy, isChat }
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export const useWeb5BadgeStore = defineStore('web5Badge', () => {
|
||||
const pendingRequestCount = ref(0)
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' })
|
||||
pendingRequestCount.value = res.requests?.length ?? 0
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Badge refresh failed — best-effort', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { pendingRequestCount, refresh }
|
||||
})
|
||||
Reference in New Issue
Block a user