feat: Archipelago demo stack (lightweight)

This commit is contained in:
Dorian
2026-03-17 02:14:04 +00:00
commit 6b15143b8a
534 changed files with 75115 additions and 0 deletions

View File

@@ -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()
}
})
})

View File

@@ -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)
})
})

View File

@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } 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 },
}))
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,
})
})
it('starts closed with empty state', () => {
const store = useAppLauncherStore()
expect(store.isOpen).toBe(false)
expect(store.url).toBe('')
expect(store.title).toBe('')
})
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' })
// Legacy overlay should NOT open — routed to session view instead
expect(store.isOpen).toBe(false)
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'filebrowser' } })
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(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'btcpay' } })
})
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(mockPush).toHaveBeenCalled()
})
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(mockPush).toHaveBeenCalled()
})
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('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 full-page session
expect(store.isOpen).toBe(false)
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: '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()
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View File

@@ -0,0 +1,227 @@
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 are running', () => {
mockPackages['bitcoin-knots'] = { state: 'running' }
mockPackages['lnd'] = { state: 'running' }
const store = useGoalStore()
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()
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()
const statuses = store.goalStatuses
expect(statuses['accept-payments']).toBe('completed')
expect(statuses['create-identity']).toBe('not-started')
expect(statuses['store-photos']).toBe('not-started')
})
})

View File

@@ -0,0 +1,52 @@
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.pendingWelcomeTyping).toBe(false)
expect(store.startWelcomeTyping).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)
})
})

View File

@@ -0,0 +1,81 @@
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('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)
})
})

View File

@@ -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)
})
})
})

View File

@@ -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
})
})

View File

@@ -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)
})
})

View File

@@ -0,0 +1,147 @@
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 = JSON.parse(stored) as AIContextCategory[]
return new Set(parsed.filter(c => AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)))
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e)
}
return new Set()
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled.value]))
}
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,
}
})

317
neode-ui/src/stores/app.ts Normal file
View File

@@ -0,0 +1,317 @@
// Main application store using Pinia
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { DataModel } from '../types/api'
import { wsClient, applyDataPatch } from '../api/websocket'
import { rpcClient } from '../api/rpc-client'
export const useAppStore = defineStore('app', () => {
// State
const data = ref<DataModel | null>(null)
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
const isConnected = ref(false)
const isReconnecting = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
let isWsSubscribed = false
let sessionValidated = false
// 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)
const serverName = computed(() => serverInfo.value?.name || 'Archipelago')
const isRestarting = computed(() => serverInfo.value?.['status-info']?.restarting || false)
const isShuttingDown = computed(() => serverInfo.value?.['status-info']?.['shutting-down'] || false)
const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value)
// 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
localStorage.setItem('neode-auth', 'true')
// Initialize data structure immediately so dashboard can render
await initializeData()
// Connect WebSocket in background - don't block login flow
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
localStorage.setItem('neode-auth', 'true')
await initializeData()
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> {
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')
data.value = null
isWsSubscribed = false
wsClient.disconnect()
isConnected.value = false
isReconnecting.value = false
}
}
async function connectWebSocket(): Promise<void> {
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
isConnected.value = true
isReconnecting.value = false
}
// Handle real backend format: {rev: 0, data: {...}}
else if (update?.data && update?.rev !== undefined) {
data.value = update.data
isConnected.value = true
isReconnecting.value = false
}
// Handle patch updates (both backends)
else if (data.value && update?.patch) {
try {
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
data.value = applyDataPatch(data.value, update.patch)
// Mark as connected once we receive any valid patch
if (!isConnected.value) {
isConnected.value = true
isReconnecting.value = false
}
} catch (err) {
if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err)
}
}
})
}
// Now connect (or reconnect if already connected)
// Only attempt to connect if not already connected
if (wsClient.isConnected()) {
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
isConnected.value = true
isReconnecting.value = false
return
}
await wsClient.connect()
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
// 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
}
}
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,
},
'package-data': {},
ui: {
name: null,
'ack-welcome': '',
marketplace: {
'selected-hosts': [],
'known-hosts': {},
},
theme: 'dark',
},
}
}
// Check session validity on app load or stale auth
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
await initializeData()
connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
isReconnecting.value = true
})
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
isWsSubscribed = false
isConnected.value = false
isReconnecting.value = false
wsClient.disconnect()
return false
}
}
function needsSessionValidation(): boolean {
return isAuthenticated.value && !sessionValidated
}
// Package actions
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
return rpcClient.installPackage(id, marketplaceUrl, version)
}
async function uninstallPackage(id: string): Promise<void> {
return rpcClient.uninstallPackage(id)
}
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)
}
// 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)
}
return {
// State
data,
isAuthenticated,
isConnected,
isReconnecting,
isLoading,
error,
// Computed
serverInfo,
packages,
peerHealth,
uiData,
serverName,
isRestarting,
isShuttingDown,
isOffline,
// Actions
login,
completeLoginAfterTotp,
logout,
checkSession,
needsSessionValidation,
connectWebSocket,
installPackage,
uninstallPackage,
startPackage,
stopPackage,
restartPackage,
updateServer,
restartServer,
shutdownServer,
getMetrics,
getMarketplace,
}
})

View File

@@ -0,0 +1,310 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
/** 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
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
'9001', // Penpot — not reachable
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
return NEW_TAB_PORTS.has(u.port)
} catch {
return false
}
}
/** Port → app ID for resolving URLs to AppSession routes */
const PORT_TO_APP_ID: Record<string, string> = {
'81': 'nginx-proxy-manager',
'3000': 'grafana',
'3001': 'uptime-kuma',
'8080': 'endurain',
'8081': 'lnd',
'8082': 'vaultwarden',
'8083': 'filebrowser',
'8085': 'nextcloud',
'8096': 'jellyfin',
'8123': 'homeassistant',
'8240': 'tailscale',
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9001': 'penpot',
'9980': 'onlyoffice',
'11434': 'ollama',
'2283': 'immich',
'23000': 'btcpay-server',
'2342': 'photoprism',
'4080': 'mempool',
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'18081': 'nostr-rs-relay',
'7777': 'indeedhub',
'50002': 'electrumx',
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
return stored ? new Set(JSON.parse(stored) as string[]) : new Set()
} catch {
return new Set()
}
}
function saveApprovedOrigin(origin: string) {
const origins = getApprovedOrigins()
origins.add(origin)
localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins]))
}
export interface NostrConsentRequest {
appName: string
method: string
eventKind?: number
content?: string
resolve: (remember: boolean) => void
reject: () => void
}
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
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 panel mode (store-based, no route change) */
const panelAppId = ref<string | null>(null)
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function openSession(appId: string) {
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null
router.push({ name: 'app-session', params: { appId } })
}
}
function closePanel() {
panelAppId.value = null
}
/** Legacy: open app in iframe overlay (kept for backward compat) */
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
// Route to full-page session if we can resolve an app ID from the URL
const resolvedId = resolveAppIdFromUrl(payload.url)
if (resolvedId) {
openSession(resolvedId)
return
}
// Apps that block iframes — open directly in new tab
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
window.open(payload.url, '_blank', 'noopener,noreferrer')
return
}
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = payload.url
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 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',
'nwnn.l484.com': 'nwnn',
'484.kitchen': '484-kitchen',
'cta.tx1138.com': 'call-the-operator',
'present.l484.com': 'arch-presentation',
'syntropy.institute': 'syntropy-institute',
'teeminuszero.net': 't-zero',
'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 = ''
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
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 = JSON.parse(stored) as { id?: string }
appIdentityId = parsed.id || null
}
} catch { /* ignore */ }
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') {
// Check if origin is pre-approved
const approved = getApprovedOrigins()
if (!approved.has(origin)) {
const eventKind = params?.event?.kind as number | undefined
const content = params?.event?.content as string | undefined
try {
const remember = await requestConsent(title.value || 'App', 'signEvent', eventKind, content)
if (remember) saveApprovedOrigin(origin)
} catch {
source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, '*')
return
}
}
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 }, '*')
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
source.postMessage({ type: 'nostr-response', id, error: message }, '*')
}
}
// 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,
showConsent,
consentRequest,
approveConsent,
denyConsent,
}
})

View File

@@ -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,
}
})

View File

@@ -0,0 +1,131 @@
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)
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> {
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 {
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = 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)
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
} catch {
// Fall back to root
const result = await fileBrowserClient.listDirectory('/')
items.value = result
currentPath.value = '/'
}
} else {
throw new Error('Failed to list root directory')
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally {
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 downloadFile(path: string): Promise<void> {
return fileBrowserClient.downloadFile(path)
}
function reset(): void {
currentPath.value = '/'
items.value = []
loading.value = false
error.value = null
}
return {
currentPath,
items,
loading,
error,
authenticated,
breadcrumbs,
sortedItems,
init,
navigate,
refresh,
uploadFile,
deleteItem,
downloadUrl,
fetchBlobUrl,
downloadFile,
reset,
}
})

View File

@@ -0,0 +1,312 @@
// 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': ['archy-mempool-web'],
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
}
export const BUNDLED_APPS: BundledApp[] = [
{
id: 'bitcoin-knots',
name: 'Bitcoin Knots',
image: 'localhost/bitcoinknots/bitcoin:29',
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: 8081, 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 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)
}
}
// 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,
enrichedBundledApps,
// Actions
fetchContainers,
fetchHealthStatus,
installApp,
startContainer,
stopContainer,
removeContainer,
getContainerLogs,
getContainerStatus,
startBundledApp,
stopBundledApp,
}
})

View File

@@ -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,
}
})

View File

@@ -0,0 +1,144 @@
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'],
}
function matchesAppId(pkgId: string, appId: string): boolean {
if (pkgId === appId) return true
const aliases = APP_ALIASES[appId]
return aliases ? aliases.includes(pkgId) : 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() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress.value))
}
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',
),
)
if (allRunning) return 'completed'
const anyInstalled = goal.requiredApps.some((appId) =>
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
)
if (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,
}
})

View File

@@ -0,0 +1,32 @@
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)
/** 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)
function setJustLoggedIn(value: boolean) {
justLoggedIn.value = value
}
function setPendingWelcomeTyping(value: boolean) {
pendingWelcomeTyping.value = value
}
function setStartWelcomeTyping(value: boolean) {
startWelcomeTyping.value = value
}
return {
justLoggedIn,
setJustLoggedIn,
pendingWelcomeTyping,
setPendingWelcomeTyping,
startWelcomeTyping,
setStartWelcomeTyping,
}
})

189
neode-ui/src/stores/mesh.ts Normal file
View File

@@ -0,0 +1,189 @@
// Pinia store for mesh networking state (Meshcore LoRa)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
export interface MeshStatus {
enabled: boolean
device_type: string
device_path: string | null
device_connected: boolean
firmware_version: string | null
self_node_id: number | null
self_advert_name: string | null
peer_count: number
channel_name: string
messages_sent: number
messages_received: number
detected_devices?: string[]
}
export interface MeshPeer {
contact_id: number
advert_name: string
did: string | null
pubkey_hex: string | null
rssi: number | null
snr: number | null
last_heard: string
hops: number
}
export interface MeshChannel {
index: number
name: string
has_secret: boolean
}
export interface MeshMessage {
id: number
direction: 'sent' | 'received'
peer_contact_id: number
peer_name: string | null
plaintext: string
timestamp: string
delivered: boolean
encrypted: boolean
}
export const useMeshStore = defineStore('mesh', () => {
const status = ref<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
const messages = ref<MeshMessage[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const sending = ref(false)
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread)
const viewingChatId = ref<number | null>(null)
// Total unread count for nav badge
const totalUnread = computed(() =>
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
)
async function fetchStatus() {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
} finally {
loading.value = false
}
}
async function fetchPeers() {
try {
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
method: 'mesh.peers',
})
peers.value = res.peers
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh peers'
}
}
async function fetchMessages(limit?: number) {
try {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages',
params: limit ? { limit } : {},
})
// Detect new incoming messages and increment unread counts
const newMsgs = res.messages.filter(
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
)
for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat
if (msg.peer_contact_id !== viewingChatId.value) {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
}
}
messages.value = res.messages
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
}
}
function markChatRead(contactId: number) {
viewingChatId.value = contactId
delete unreadCounts.value[contactId]
}
function clearViewingChat() {
viewingChatId.value = null
}
async function sendMessage(contactId: number, message: string) {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; encrypted: boolean }>({
method: 'mesh.send',
params: { contact_id: contactId, message: message.trim() },
})
// Refresh messages after sending
if (res.sent) {
await fetchMessages()
}
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send mesh message'
throw err
} finally {
sending.value = false
}
}
async function broadcastIdentity() {
try {
error.value = null
await rpcClient.call<{ broadcast: boolean }>({ method: 'mesh.broadcast' })
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to broadcast identity'
throw err
}
}
async function configure(config: Partial<MeshStatus>) {
try {
error.value = null
await rpcClient.call<{ configured: boolean }>({
method: 'mesh.configure',
params: config,
})
await fetchStatus()
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to configure mesh'
throw err
}
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()])
}
return {
status,
peers,
messages,
loading,
error,
sending,
unreadCounts,
totalUnread,
fetchStatus,
fetchPeers,
fetchMessages,
sendMessage,
broadcastIdentity,
configure,
refreshAll,
markChatRead,
clearViewingChat,
}
})

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes
export const useScreensaverStore = defineStore('screensaver', () => {
const isActive = ref(false)
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
function activate() {
isActive.value = true
clearInactivityTimer()
}
function deactivate() {
isActive.value = false
resetInactivityTimer()
}
function resetInactivityTimer() {
clearInactivityTimer()
inactivityTimer = setTimeout(() => {
inactivityTimer = null
isActive.value = true
}, INACTIVITY_MS)
}
function clearInactivityTimer() {
if (inactivityTimer) {
clearTimeout(inactivityTimer)
inactivityTimer = null
}
}
return {
isActive,
activate,
deactivate,
resetInactivityTimer,
clearInactivityTimer,
}
})

View File

@@ -0,0 +1,105 @@
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 = JSON.parse(raw) as RecentItem[]
recentItems.value = parsed.slice(0, MAX_RECENT_ITEMS)
} 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,
}
})

View File

@@ -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' | '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,
}
})

View File

@@ -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
localStorage.setItem(STORAGE_KEY, backendMode)
}
}
function setMode(newMode: UIMode) {
mode.value = newMode
localStorage.setItem(STORAGE_KEY, newMode)
}
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 }
})

View File

@@ -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 }
})