refactor: update dependencies and remove unused code

- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`.
- Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27.
- Removed the `backup.rs` file as it is no longer needed.
- Introduced tests for configuration and credential management.
- Enhanced the `identity` module to generate W3C compliant DID documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 00:19:30 +00:00
co-authored by Claude Opus 4.6
parent fd2a837bea
commit f07ce10b1a
347 changed files with 18703 additions and 46785 deletions
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the rpc-client module
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
import { containerClient } from '../container-client'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('containerClient', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('installApp calls container-install with manifest path', async () => {
mockedRpc.call.mockResolvedValue('container-abc123')
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-install',
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
})
expect(result).toBe('container-abc123')
})
it('startContainer calls container-start with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.startContainer('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-start',
params: { app_id: 'bitcoin-knots' },
})
})
it('stopContainer calls container-stop with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopContainer('lnd')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-stop',
params: { app_id: 'lnd' },
})
})
it('removeContainer calls container-remove with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.removeContainer('mempool')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-remove',
params: { app_id: 'mempool' },
})
})
it('getContainerStatus returns status for a container', async () => {
const mockStatus = {
id: '1',
name: 'bitcoin-knots',
state: 'running' as const,
image: 'bitcoinknots:29',
created: '2026-01-01',
ports: ['8332'],
lan_address: 'http://localhost:8332',
}
mockedRpc.call.mockResolvedValue(mockStatus)
const result = await containerClient.getContainerStatus('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-status',
params: { app_id: 'bitcoin-knots' },
})
expect(result).toEqual(mockStatus)
})
it('getContainerLogs returns log lines with default line count', async () => {
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
mockedRpc.call.mockResolvedValue(mockLogs)
const result = await containerClient.getContainerLogs('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'bitcoin-knots', lines: 100 },
})
expect(result).toEqual(mockLogs)
})
it('getContainerLogs respects custom line count', async () => {
mockedRpc.call.mockResolvedValue([])
await containerClient.getContainerLogs('lnd', 50)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'lnd', lines: 50 },
})
})
it('listContainers returns all containers', async () => {
const mockContainers = [
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
]
mockedRpc.call.mockResolvedValue(mockContainers)
const result = await containerClient.listContainers()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-list',
params: {},
})
expect(result).toHaveLength(2)
})
it('getHealthStatus returns health map', async () => {
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
mockedRpc.call.mockResolvedValue(mockHealth)
const result = await containerClient.getHealthStatus()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-health',
params: {},
})
expect(result).toEqual(mockHealth)
})
it('startBundledApp sends full app config', async () => {
mockedRpc.call.mockResolvedValue(undefined)
const app = {
id: 'filebrowser',
name: 'FileBrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
}
await containerClient.startBundledApp(app)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-start',
params: {
app_id: 'filebrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
},
})
})
it('stopBundledApp calls bundled-app-stop', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopBundledApp('filebrowser')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-stop',
params: { app_id: 'filebrowser' },
})
})
it('propagates RPC errors from the client', async () => {
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
})
})
@@ -0,0 +1,330 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { sanitizePath } from '../filebrowser-client'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in constructor, so stub it
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost' },
writable: true,
})
// Import after stubs
const { fileBrowserClient } = await import('../filebrowser-client')
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
formData: () => Promise.resolve(new FormData()),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('FileBrowserClient', () => {
beforeEach(() => {
mockFetch.mockReset()
})
describe('login', () => {
it('authenticates and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
// We need a fresh instance to test login — use the exported singleton
const result = await fileBrowserClient.login('admin', 'admin')
expect(result).toBe(true)
expect(fileBrowserClient.isAuthenticated).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/app/filebrowser/api/login'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ username: 'admin', password: 'admin' }),
}),
)
})
it('returns false on failed login', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
const result = await fileBrowserClient.login('admin', 'wrong')
expect(result).toBe(false)
})
it('returns false on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const result = await fileBrowserClient.login()
expect(result).toBe(false)
})
})
describe('listDirectory', () => {
it('lists items in a directory', async () => {
// Ensure authenticated first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
const mockItems = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
],
numDirs: 1,
numFiles: 1,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
const items = await fileBrowserClient.listDirectory('/')
expect(items).toHaveLength(2)
expect(items[0]!.name).toBe('photos')
expect(items[1]!.extension).toBe('txt')
})
it('adds leading slash if missing', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
await fileBrowserClient.listDirectory('photos')
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos')
})
it('throws on non-OK response', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
})
})
describe('downloadUrl', () => {
it('constructs download URL for file path', async () => {
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
expect(url).toContain('/api/raw/photos/sunset.jpg')
})
it('adds leading slash if missing', async () => {
const url = fileBrowserClient.downloadUrl('file.txt')
expect(url).toContain('/api/raw/file.txt')
})
})
describe('upload', () => {
it('uploads a file to the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
await fileBrowserClient.upload('/documents', file)
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/test.txt')
expect(url).toContain('override=true')
expect(init.method).toBe('POST')
expect(init.body).toBe(file)
})
it('throws on upload failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
const file = new File(['data'], 'big.bin')
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
})
})
describe('createFolder', () => {
it('creates a folder at the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.createFolder('/documents', 'photos')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/photos/')
expect(init.method).toBe('POST')
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
})
})
describe('deleteItem', () => {
it('sends DELETE request for the item', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.deleteItem('/photos/old.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('DELETE')
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
})
})
describe('getUsage', () => {
it('returns usage summary for root directory', async () => {
// Login first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
const mockData = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
],
numDirs: 1,
numFiles: 2,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
const usage = await fileBrowserClient.getUsage()
expect(usage.totalSize).toBe(2000)
expect(usage.folderCount).toBe(1)
expect(usage.fileCount).toBe(2)
})
it('returns zeros on failed request', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
const usage = await fileBrowserClient.getUsage()
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
})
})
describe('isTextFile', () => {
it('identifies text file extensions', () => {
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
})
it('returns false for binary files', () => {
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
})
})
describe('rename', () => {
it('sends PATCH request with new destination', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('PATCH')
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
})
it('throws on rename failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
})
})
})
describe('sanitizePath', () => {
it('returns / for empty path', () => {
expect(sanitizePath('')).toBe('/')
})
it('preserves simple paths', () => {
expect(sanitizePath('/photos')).toBe('/photos')
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
})
it('adds leading slash', () => {
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
})
it('resolves . segments', () => {
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
})
it('resolves .. segments', () => {
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
})
it('prevents traversal past root', () => {
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
expect(sanitizePath('/../../..')).toBe('/')
})
it('handles multiple consecutive .. at root', () => {
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
})
it('handles mixed . and .. segments', () => {
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
})
it('removes trailing slashes in segments', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
})
})
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing fetch
const { rpcClient } = await import('../rpc-client')
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
json: () => Promise.resolve(body),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status, statusText),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve(JSON.stringify(body)),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('marketplaceDiscover', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns apps array and relay_count on success', async () => {
const payload = {
apps: [
{
manifest: {
app_id: 'bitcoin',
name: 'Bitcoin Core',
version: '27.0',
description: { short: 'Full node', long: 'Bitcoin Core full node' },
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
category: 'bitcoin',
icon_url: '/icons/bitcoin.png',
repo_url: 'https://github.com/bitcoin/bitcoin',
license: 'MIT',
},
trust_score: 95,
trust_tier: 'verified',
relay_count: 8,
first_seen: '2025-01-15T00:00:00Z',
nostr_pubkey: 'npub1abc',
},
],
relay_count: 12,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toHaveLength(1)
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
expect(result.apps[0]!.trust_score).toBe(95)
expect(result.relay_count).toBe(12)
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('marketplace.discover')
expect(body.params).toEqual({})
})
it('handles empty results', async () => {
const payload = {
apps: [],
relay_count: 0,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toEqual([])
expect(result.apps).toHaveLength(0)
expect(result.relay_count).toBe(0)
})
})
describe('diskStatus', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns expected fields', async () => {
const payload = {
used_bytes: 500_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 500_000_000_000,
used_percent: 50,
level: 'ok' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.used_bytes).toBe(500_000_000_000)
expect(result.total_bytes).toBe(1_000_000_000_000)
expect(result.free_bytes).toBe(500_000_000_000)
expect(result.used_percent).toBe(50)
expect(result.level).toBe('ok')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-status')
})
it('level is warning when percent >= 85', async () => {
const payload = {
used_bytes: 850_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 150_000_000_000,
used_percent: 85,
level: 'warning' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('warning')
expect(result.used_percent).toBe(85)
})
it('level is critical when percent >= 90', async () => {
const payload = {
used_bytes: 950_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 50_000_000_000,
used_percent: 95,
level: 'critical' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('critical')
expect(result.used_percent).toBe(95)
})
})
describe('diskCleanup', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns freed_bytes and actions array', async () => {
const payload = {
freed_bytes: 2_000_000_000,
freed_human: '2 GB',
actions: ['Removed 5 dangling images', 'Cleared build cache'],
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskCleanup()
expect(result.freed_bytes).toBe(2_000_000_000)
expect(result.freed_human).toBe('2 GB')
expect(result.actions).toHaveLength(2)
expect(result.actions[0]).toBe('Removed 5 dangling images')
expect(result.actions[1]).toBe('Cleared build cache')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-cleanup')
})
it('uses 60s timeout', async () => {
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
mockFetch.mockRejectedValue(abortError)
const promise = rpcClient.diskCleanup()
// The call should eventually reject with timeout after retries
await expect(promise).rejects.toThrow('Request timeout')
// Verify all 3 attempts used the signal (timeout is set via AbortController)
expect(mockFetch).toHaveBeenCalledTimes(3)
for (const call of mockFetch.mock.calls) {
expect(call[1].signal).toBeInstanceOf(AbortSignal)
}
})
})
+69 -11
View File
@@ -15,6 +15,26 @@ interface FileBrowserListResponse {
sorting: { by: string; asc: boolean }
}
/**
* Normalize a path: resolve `.` and `..`, reject traversal outside root.
* Always returns a path starting with `/` and never containing `..`.
*/
export function sanitizePath(path: string): string {
const segments = path.split('/').filter(Boolean)
const resolved: string[] = []
for (const seg of segments) {
if (seg === '.') continue
if (seg === '..') {
resolved.pop() // go up one level, but never past root
} else {
resolved.push(seg)
}
}
return '/' + resolved.join('/')
}
class FileBrowserClient {
private token: string | null = null
private baseUrl: string
@@ -38,6 +58,8 @@ class FileBrowserClient {
const text = await res.text()
// FileBrowser returns the JWT as a plain string (possibly quoted)
this.token = text.replace(/^"|"$/g, '')
// Store token as cookie for img/video/audio src requests (avoids token in URL)
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict`
return true
} catch {
return false
@@ -51,7 +73,7 @@ class FileBrowserClient {
}
async listDirectory(path: string): Promise<FileBrowserItem[]> {
const safePath = path.startsWith('/') ? path : `/${path}`
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
headers: this.headers(),
})
@@ -63,14 +85,47 @@ class FileBrowserClient {
}))
}
/**
* @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs.
* Returns a plain URL (no token in query string).
*/
downloadUrl(path: string): string {
const safePath = path.startsWith('/') ? path : `/${path}`
// Token is passed as query param for direct downloads (img src, audio src, etc.)
return `${this.baseUrl}/api/raw${safePath}?auth=${this.token}`
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}`
}
/**
* Fetch a file as a blob URL using header-based auth (no token in URL).
* Use this for img/video/audio src attributes and download links.
*/
async fetchBlobUrl(path: string): Promise<string> {
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob()
return URL.createObjectURL(blob)
}
/**
* Trigger a file download using header-based auth (no token in URL).
*/
async downloadFile(path: string): Promise<void> {
const blobUrl = await this.fetchBlobUrl(path)
const filename = path.split('/').pop() || 'download'
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
}
async upload(dirPath: string, file: File): Promise<void> {
const safePath = dirPath.endsWith('/') ? dirPath : `${dirPath}/`
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
const res = await fetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
@@ -87,8 +142,10 @@ class FileBrowserClient {
}
async createFolder(parentPath: string, name: string): Promise<void> {
const safePath = parentPath.endsWith('/') ? parentPath : `${parentPath}/`
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${name}/`, {
const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
method: 'POST',
headers: this.headers(),
})
@@ -96,7 +153,7 @@ class FileBrowserClient {
}
async deleteItem(path: string): Promise<void> {
const safePath = path.startsWith('/') ? path : `/${path}`
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE',
headers: this.headers(),
@@ -142,7 +199,7 @@ class FileBrowserClient {
if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`)
}
const safePath = path.startsWith('/') ? path : `/${path}`
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
})
@@ -156,15 +213,16 @@ class FileBrowserClient {
}
async rename(oldPath: string, newName: string): Promise<void> {
const safePath = oldPath.startsWith('/') ? oldPath : `/${oldPath}`
const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'PATCH',
headers: {
...this.headers(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ destination: `${dir}${newName}` }),
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
})
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
}
+291
View File
@@ -211,6 +211,62 @@ class RPCClient {
})
}
async resolveDid(did?: string): Promise<Record<string, unknown>> {
return this.call({
method: 'identity.resolve-did',
params: did ? { did } : {},
})
}
async createPresentation(params: {
holderId: string
credentialIds: string[]
}): Promise<Record<string, unknown>> {
return this.call({
method: 'identity.create-presentation',
params: { holder_id: params.holderId, credential_ids: params.credentialIds },
})
}
async verifyPresentation(presentation: Record<string, unknown>): Promise<{
valid: boolean
holder_valid: boolean
credentials: Array<{ id: string; valid: boolean; revoked: boolean }>
}> {
return this.call({
method: 'identity.verify-presentation',
params: { presentation },
})
}
async createPsbt(params: {
outputs: Array<{ address: string; amount_sats: number }>
feeRateSatPerVbyte?: number
}): Promise<{
psbt_base64: string
change_output_index: number
total_amount_sats: number
fee_rate_sat_per_vbyte: number
}> {
return this.call({
method: 'lnd.create-psbt',
params: {
outputs: params.outputs,
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
},
})
}
async finalizePsbt(signedPsbtBase64: string): Promise<{
raw_final_tx: string
broadcast: boolean
}> {
return this.call({
method: 'lnd.finalize-psbt',
params: { signed_psbt_base64: signedPsbtBase64 },
})
}
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
return this.call({
method: 'node.nostr-publish',
@@ -325,6 +381,21 @@ class RPCClient {
})
}
async detectUsbDevices(): Promise<{
devices: Array<{
type: string
vendor_id: string
product_id: string
manufacturer: string
product: string
}>
}> {
return this.call({
method: 'system.detect-usb-devices',
params: {},
})
}
async restartServer(): Promise<void> {
return this.call({
method: 'server.restart',
@@ -381,6 +452,226 @@ class RPCClient {
})
}
// Federation
async federationInvite(): Promise<{ code: string; did: string; onion: string }> {
return this.call({
method: 'federation.invite',
params: {},
})
}
async federationJoin(code: string): Promise<{
joined: boolean
node: { did: string; onion: string; pubkey: string; trust_level: string }
}> {
return this.call({
method: 'federation.join',
params: { code },
})
}
async federationListNodes(): Promise<{
nodes: Array<{
did: string
pubkey: string
onion: string
trust_level: string
added_at: string
name?: string
last_seen?: string
last_state?: {
timestamp: string
apps: Array<{ id: string; status: string; version?: string }>
cpu_usage_percent?: number
mem_used_bytes?: number
mem_total_bytes?: number
disk_used_bytes?: number
disk_total_bytes?: number
uptime_secs?: number
tor_active?: boolean
}
}>
}> {
return this.call({
method: 'federation.list-nodes',
params: {},
})
}
async federationRemoveNode(did: string): Promise<{ removed: boolean; nodes_remaining: number }> {
return this.call({
method: 'federation.remove-node',
params: { did },
})
}
async federationSetTrust(
did: string,
trustLevel: 'trusted' | 'observer' | 'untrusted',
): Promise<{ updated: boolean; did: string; trust_level: string }> {
return this.call({
method: 'federation.set-trust',
params: { did, trust_level: trustLevel },
})
}
async federationSyncState(): Promise<{
synced: number
failed: number
results: Array<{ did: string; status: string; apps?: number; error?: string }>
}> {
return this.call({
method: 'federation.sync-state',
params: {},
timeout: 120000,
})
}
async federationDeployApp(params: {
did: string
appId: string
version?: string
marketplaceUrl?: string
}): Promise<{ deployed: boolean; app_id: string; peer_did: string; peer_onion: string }> {
return this.call({
method: 'federation.deploy-app',
params: {
did: params.did,
app_id: params.appId,
version: params.version ?? 'latest',
marketplace_url: params.marketplaceUrl ?? '',
},
timeout: 180000,
})
}
// VPN
async vpnStatus(): Promise<{
connected: boolean
provider?: string
interface?: string
ip_address?: string
hostname?: string
peers_connected: number
bytes_in: number
bytes_out: number
configured: boolean
configured_provider: string
}> {
return this.call({
method: 'vpn.status',
params: {},
})
}
async vpnConfigure(params: {
provider: 'tailscale' | 'wireguard'
auth_key?: string
address?: string
dns?: string
peer?: {
public_key: string
endpoint: string
allowed_ips?: string
persistent_keepalive?: number
}
}): Promise<{ configured: boolean; provider: string; public_key?: string; address?: string }> {
return this.call({
method: 'vpn.configure',
params,
timeout: 60000,
})
}
async vpnDisconnect(): Promise<{ disconnected: boolean }> {
return this.call({
method: 'vpn.disconnect',
params: {},
})
}
// Marketplace
async marketplaceDiscover(): Promise<{
apps: Array<{
manifest: {
app_id: string
name: string
version: string
description: { short: string; long: string } | string
author: { name: string; did: string; nostr_pubkey: string }
container: { image: string; ports: Array<{ container: number; host: number }>; }
category: string
icon_url: string
repo_url: string
license: string
}
trust_score: number
trust_tier: string
relay_count: number
first_seen: string
nostr_pubkey: string
}>
relay_count: number
}> {
return this.call({
method: 'marketplace.discover',
params: {},
timeout: 30000,
})
}
// DNS
async dnsStatus(): Promise<{
provider: string
servers: string[]
doh_enabled: boolean
doh_url: string | null
resolv_conf_servers: string[]
}> {
return this.call({
method: 'network.dns-status',
params: {},
})
}
async configureDns(params: {
provider: 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
servers?: string[]
}): Promise<{
ok: boolean
provider: string
servers: string[]
doh_enabled: boolean
doh_url: string | null
}> {
return this.call({
method: 'network.configure-dns',
params,
})
}
// Disk management
async diskStatus(): Promise<{
used_bytes: number
total_bytes: number
free_bytes: number
used_percent: number
level: 'ok' | 'warning' | 'critical'
}> {
return this.call({ method: 'system.disk-status' })
}
async diskCleanup(): Promise<{
freed_bytes: number
freed_human: string
actions: string[]
}> {
return this.call({
method: 'system.disk-cleanup',
timeout: 60000,
})
}
}
export const rpcClient = new RPCClient()
+64 -36
View File
@@ -3,8 +3,10 @@
import type { Update, PatchOperation } from '../types/api'
import { applyPatch, type Operation } from 'fast-json-patch'
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
type WebSocketCallback = (update: Update) => void
type ConnectionStateCallback = (connected: boolean) => void
type ConnectionStateCallback = (state: ConnectionState) => void
export class WebSocketClient {
private ws: WebSocket | null = null
@@ -13,14 +15,18 @@ export class WebSocketClient {
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectDelay = 1000
private maxReconnectDelay = 30000
private shouldReconnect = true
private url: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private visibilityChangeHandler: (() => void) | null = null
private onlineHandler: (() => void) | null = null
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private lastMessageTime: number = Date.now()
private heartbeatInterval = 10000 // Check connection every 10 seconds
private pingInterval = 30000 // Send ping every 30 seconds
private _state: ConnectionState = 'disconnected'
constructor(url: string = '/ws/db') {
this.url = url
@@ -33,13 +39,13 @@ export class WebSocketClient {
// Handle page visibility changes (tab switching, browser minimizing)
this.visibilityChangeHandler = () => {
if (document.visibilityState === 'visible') {
console.log('[WebSocket] Page became visible, checking connection...')
if (import.meta.env.DEV) console.log('[WebSocket] Page became visible, checking connection...')
// Only reconnect if we haven't been explicitly disconnected
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
if (import.meta.env.DEV) console.log('[WebSocket] Connection lost while hidden, reconnecting...')
this.reconnectAttempts = 0
this.connect().catch(err => {
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect on visibility change:', err)
})
}
}
@@ -50,11 +56,11 @@ export class WebSocketClient {
this.onlineHandler = () => {
// Only reconnect if we haven't been explicitly disconnected
if (!this.shouldReconnect) return
console.log('[WebSocket] Network came online, reconnecting...')
if (import.meta.env.DEV) console.log('[WebSocket] Network came online, reconnecting...')
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.reconnectAttempts = 0
this.connect().catch(err => {
console.error('[WebSocket] Failed to reconnect when network came online:', err)
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect when network came online:', err)
})
}
}
@@ -65,14 +71,14 @@ export class WebSocketClient {
return new Promise((resolve, reject) => {
// If already connected, resolve immediately
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] Already connected, skipping')
if (import.meta.env.DEV) console.log('[WebSocket] Already connected, skipping')
resolve()
return
}
// If connecting, wait for it
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
console.log('[WebSocket] Already connecting, waiting...')
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
const checkInterval = setInterval(() => {
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
@@ -107,7 +113,7 @@ export class WebSocketClient {
// If we have an active WebSocket, don't create a new one
if (this.ws) {
console.log('[WebSocket] Connection exists, reusing it')
if (import.meta.env.DEV) console.log('[WebSocket] Connection exists, reusing it')
resolve()
return
}
@@ -124,14 +130,15 @@ export class WebSocketClient {
const host = window.location.host
const wsUrl = `${protocol}//${host}${this.url}`
console.log('[WebSocket] Connecting to:', wsUrl)
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to:', wsUrl)
this.setConnectionState('connecting')
this.ws = new WebSocket(wsUrl)
// Timeout handler in case connection hangs
const connectionTimeout = setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
console.warn('WebSocket connection timeout, retrying...')
if (import.meta.env.DEV) console.warn('WebSocket connection timeout, retrying...')
this.ws.close()
reject(new Error('Connection timeout'))
}
@@ -141,15 +148,15 @@ export class WebSocketClient {
clearTimeout(connectionTimeout)
this.reconnectAttempts = 0
this.lastMessageTime = Date.now()
console.log('[WebSocket] Connected successfully')
this.notifyConnectionState(true)
if (import.meta.env.DEV) console.log('[WebSocket] Connected successfully')
this.setConnectionState('connected')
this.startHeartbeat()
resolve()
}
this.ws.onerror = (error) => {
clearTimeout(connectionTimeout)
console.error('[WebSocket] Connection error:', error)
if (import.meta.env.DEV) console.error('[WebSocket] Connection error:', error)
// Don't reject immediately - let onclose handle reconnection
// This prevents errors from blocking reconnection
}
@@ -160,24 +167,24 @@ export class WebSocketClient {
const update: Update = JSON.parse(event.data)
this.callbacks.forEach((callback) => callback(update))
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
if (import.meta.env.DEV) console.error('Failed to parse WebSocket message:', error)
}
}
this.ws.onclose = (event) => {
clearTimeout(connectionTimeout)
this.stopHeartbeat()
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
if (import.meta.env.DEV) console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
// Notify connection state changed
this.notifyConnectionState(false)
this.setConnectionState('disconnected')
// Clear the WebSocket reference
this.ws = null
// Don't reconnect if we explicitly disconnected
if (!this.shouldReconnect) {
console.log('[WebSocket] Reconnection disabled')
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection disabled')
return
}
@@ -190,11 +197,11 @@ export class WebSocketClient {
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
const delay = needsImmediateReconnect ? 0 :
(this.reconnectAttempts === 0 ? 100 :
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
const delay = needsImmediateReconnect ? 0 :
(this.reconnectAttempts === 0 ? 100 :
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay))
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
if (import.meta.env.DEV) console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
// Clear any existing reconnect timer
if (this.reconnectTimer) {
@@ -213,9 +220,9 @@ export class WebSocketClient {
this.reconnectAttempts++
}
console.log('[WebSocket] Attempting reconnection...')
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
this.connect().catch((err) => {
console.error('[WebSocket] Reconnection failed:', err)
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
// onclose will be called again and will retry
})
}
@@ -230,7 +237,7 @@ export class WebSocketClient {
}, delay)
}
} else {
console.warn('[WebSocket] Max reconnection attempts reached')
if (import.meta.env.DEV) console.warn('[WebSocket] Max reconnection attempts reached')
this.shouldReconnect = false
}
}
@@ -244,6 +251,10 @@ export class WebSocketClient {
}
}
get state(): ConnectionState {
return this._state
}
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
this.connectionStateCallbacks.add(callback)
return () => {
@@ -251,26 +262,38 @@ export class WebSocketClient {
}
}
private notifyConnectionState(connected: boolean): void {
this.connectionStateCallbacks.forEach((callback) => callback(connected))
private setConnectionState(state: ConnectionState): void {
this._state = state
this.connectionStateCallbacks.forEach((callback) => callback(state))
}
private startHeartbeat(): void {
this.stopHeartbeat()
// Send ping messages every 30s
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'ping' }))
} catch {
// Send failed, connection likely broken
}
}
}, this.pingInterval)
// Check connection health every 10s
this.heartbeatTimer = setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('[WebSocket] Heartbeat detected closed connection')
if (import.meta.env.DEV) console.warn('[WebSocket] Heartbeat detected closed connection')
this.stopHeartbeat()
return
}
// Check if we've received a message recently
const timeSinceLastMessage = Date.now() - this.lastMessageTime
// If no message for more than 5 minutes, assume connection is stale
if (timeSinceLastMessage > 300000) {
console.warn('[WebSocket] No messages for 5m, reconnecting...')
if (import.meta.env.DEV) console.warn('[WebSocket] No messages for 5m, reconnecting...')
this.ws.close()
return
}
@@ -282,11 +305,16 @@ export class WebSocketClient {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
disconnect(): void {
this.shouldReconnect = false
this.reconnectAttempts = 0
this.setConnectionState('disconnecting')
this.stopHeartbeat()
// Clear reconnect timer
@@ -345,7 +373,7 @@ function getWebSocketClient(): WebSocketClient {
if (existing && existing instanceof WebSocketClient) {
// Check if the WebSocket is still valid
if (existing.isConnected()) {
console.log('[WebSocket] Using existing connected client from HMR')
if (import.meta.env.DEV) console.log('[WebSocket] Using existing connected client from HMR')
wsClientInstance = existing
return existing
}
@@ -374,7 +402,7 @@ export const wsClient: WebSocketClient = (() => {
_wsClient = getWebSocketClient()
return _wsClient
} catch (error) {
console.error('[WebSocket] Error initializing client:', error)
if (import.meta.env.DEV) console.error('[WebSocket] Error initializing client:', error)
// Fallback to new instance
_wsClient = new WebSocketClient()
return _wsClient
@@ -385,7 +413,7 @@ export const wsClient: WebSocketClient = (() => {
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
// Validate patch is an array before applying
if (!Array.isArray(patch) || patch.length === 0) {
console.warn('Invalid or empty patch received, returning original data')
if (import.meta.env.DEV) console.warn('Invalid or empty patch received, returning original data')
return data
}
@@ -393,7 +421,7 @@ export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
const result = applyPatch(data, patch as Operation[], false, false)
return result.newDocument as T
} catch (error) {
console.error('Failed to apply patch:', error, 'Patch:', patch)
if (import.meta.env.DEV) console.error('Failed to apply patch:', error, 'Patch:', patch)
return data // Return original data on error
}
}
+172
View File
@@ -0,0 +1,172 @@
<template>
<canvas
ref="canvasRef"
class="monitoring-chart"
:width="width"
:height="height"
></canvas>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
export interface ChartDataset {
label: string
data: number[]
color: string
}
const props = withDefaults(
defineProps<{
datasets: ChartDataset[]
labels?: string[]
width?: number
height?: number
yMax?: number
yLabel?: string
showGrid?: boolean
}>(),
{
width: 400,
height: 180,
showGrid: true,
},
)
const canvasRef = ref<HTMLCanvasElement | null>(null)
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
canvas.width = props.width * dpr
canvas.height = props.height * dpr
canvas.style.width = `${props.width}px`
canvas.style.height = `${props.height}px`
ctx.scale(dpr, dpr)
const w = props.width
const h = props.height
const pad = { top: 10, right: 12, bottom: 24, left: 44 }
const plotW = w - pad.left - pad.right
const plotH = h - pad.top - pad.bottom
// Clear
ctx.clearRect(0, 0, w, h)
if (!props.datasets.length || !props.datasets[0]?.data.length) {
ctx.fillStyle = 'rgba(255,255,255,0.3)'
ctx.font = '12px system-ui'
ctx.textAlign = 'center'
ctx.fillText('No data yet', w / 2, h / 2)
return
}
// Compute y range
let yMax = props.yMax ?? 0
if (!yMax) {
for (const ds of props.datasets) {
for (const v of ds.data) {
if (v > yMax) yMax = v
}
}
yMax = yMax * 1.1 || 1
}
const maxPoints = Math.max(...props.datasets.map((d) => d.data.length))
// Grid lines
if (props.showGrid) {
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
ctx.lineWidth = 1
const gridCount = 4
for (let i = 0; i <= gridCount; i++) {
const y = pad.top + (plotH / gridCount) * i
ctx.beginPath()
ctx.moveTo(pad.left, y)
ctx.lineTo(pad.left + plotW, y)
ctx.stroke()
}
// Y-axis labels
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.font = '10px system-ui'
ctx.textAlign = 'right'
for (let i = 0; i <= gridCount; i++) {
const y = pad.top + (plotH / gridCount) * i
const val = yMax - (yMax / gridCount) * i
ctx.fillText(formatValue(val), pad.left - 6, y + 3)
}
}
// Draw each dataset
for (const ds of props.datasets) {
if (!ds.data.length) continue
ctx.strokeStyle = ds.color
ctx.lineWidth = 1.5
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.beginPath()
for (let i = 0; i < ds.data.length; i++) {
const x = pad.left + (i / Math.max(maxPoints - 1, 1)) * plotW
const y = pad.top + plotH - (ds.data[i]! / yMax) * plotH
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
// Area fill
ctx.globalAlpha = 0.08
ctx.fillStyle = ds.color
ctx.lineTo(pad.left + ((ds.data.length - 1) / Math.max(maxPoints - 1, 1)) * plotW, pad.top + plotH)
ctx.lineTo(pad.left, pad.top + plotH)
ctx.closePath()
ctx.fill()
ctx.globalAlpha = 1.0
}
// X-axis labels (first, middle, last)
if (props.labels && props.labels.length > 0) {
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.font = '10px system-ui'
ctx.textAlign = 'center'
const indices = [0, Math.floor(props.labels.length / 2), props.labels.length - 1]
for (const idx of indices) {
if (idx >= 0 && idx < props.labels.length) {
const x = pad.left + (idx / Math.max(props.labels.length - 1, 1)) * plotW
ctx.fillText(props.labels[idx]!, pad.left + plotW + pad.right > w ? x : x, h - 6)
}
}
}
}
function formatValue(val: number): string {
if (val >= 1_000_000_000) return `${(val / 1_000_000_000).toFixed(1)}G`
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`
return val.toFixed(val < 10 ? 1 : 0)
}
watch(
() => [props.datasets, props.labels, props.width, props.height],
() => draw(),
{ deep: true },
)
onMounted(() => {
draw()
window.addEventListener('resize', draw)
})
onUnmounted(() => {
window.removeEventListener('resize', draw)
})
</script>
+3 -3
View File
@@ -46,7 +46,7 @@ onMounted(() => {
// Don't show if already dismissed this session or if already installed
if (sessionStorage.getItem(DISMISS_KEY) === '1') return
if (window.matchMedia('(display-mode: standalone)').matches) return
if ((window.navigator as any).standalone) return
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return
const handler = (e: Event) => {
e.preventDefault()
@@ -55,11 +55,11 @@ onMounted(() => {
}
window.addEventListener('beforeinstallprompt', handler)
;(window as any).__beforeinstallpromptHandler = handler
;(window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler = handler
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', (window as any).__beforeinstallpromptHandler)
window.removeEventListener('beforeinstallprompt', (window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler as EventListener)
})
function dismiss() {
+3 -3
View File
@@ -205,7 +205,7 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
if ((welcome || logo) && videoElement.value) {
if (videoElement.value.paused) {
videoElement.value.play().catch(err => {
console.warn('Video autoplay failed:', err)
if (import.meta.env.DEV) console.warn('Video autoplay failed:', err)
})
}
// Add pause prevention handler once, remove when no longer needed
@@ -228,7 +228,7 @@ watch(showWelcome, (isShowing) => {
if (isShowing && videoElement.value) {
// Start video immediately when welcome appears
videoElement.value.play().catch(err => {
console.warn('Video autoplay failed on welcome:', err)
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
})
}
})
@@ -414,7 +414,7 @@ function startAlienIntro() {
playWelcomeNoderunnerSpeech()
if (videoElement.value) {
videoElement.value.play().catch(err => {
console.warn('Video autoplay failed on welcome:', err)
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
})
}
backgroundOpacity.value = 0.3
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import LineChart from '../LineChart.vue'
// Mock canvas context
const mockContext = {
clearRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
closePath: vi.fn(),
setLineDash: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
scale: vi.fn(),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn(),
}),
canvas: { width: 600, height: 200 },
strokeStyle: '',
fillStyle: '',
lineWidth: 0,
font: '',
textAlign: '',
textBaseline: '',
globalAlpha: 1,
}
beforeEach(() => {
vi.clearAllMocks()
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockContext)
})
describe('LineChart', () => {
const sampleDatasets = [
{ label: 'CPU', data: [10, 20, 30, 40, 50], color: '#fb923c' },
]
it('renders a canvas element', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('accepts datasets prop', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.exists()).toBe(true)
})
it('renders with empty datasets', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: [] },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('renders with multiple datasets', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [
{ label: 'CPU', data: [10, 20, 30], color: '#fb923c' },
{ label: 'Memory', data: [50, 60, 70], color: '#4ade80' },
],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts optional height and width props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
height: 300,
width: 600,
},
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('600')
expect(canvas.attributes('height')).toBe('300')
})
it('uses default width of 400 and height of 180', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('400')
expect(canvas.attributes('height')).toBe('180')
})
it('renders with dataset containing single data point', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [{ label: 'Test', data: [42], color: '#3b82f6' }],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts yMax and yLabel props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
yMax: 100,
yLabel: 'Percent',
},
})
expect(wrapper.exists()).toBe(true)
})
})
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import PWAInstallPrompt from '../PWAInstallPrompt.vue'
describe('PWAInstallPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
// Mock matchMedia to return non-standalone
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
})
it('renders without errors', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.exists()).toBe(true)
})
it('does not show prompt initially', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('shows prompt after beforeinstallprompt event', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire the beforeinstallprompt event
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Install Archipelago')
})
it('hides prompt when dismissed', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Show prompt
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
// Click dismiss button
const dismissBtn = wrapper.findAll('button').find(b => b.text().includes('Not now'))
expect(dismissBtn).toBeDefined()
await dismissBtn!.trigger('click')
expect(sessionStorage.getItem('archipelago_pwa_install_dismissed')).toBe('1')
})
it('does not show if already dismissed this session', async () => {
sessionStorage.setItem('archipelago_pwa_install_dismissed', '1')
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire beforeinstallprompt — should not show
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('does not show in standalone mode', async () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
})
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAudioPlayer } from '../useAudioPlayer'
// Mock HTMLAudioElement
class MockAudio {
src = ''
currentTime = 0
duration = 120
paused = true
private listeners: Record<string, Array<() => void>> = {}
addEventListener(event: string, handler: () => void) {
if (!this.listeners[event]) this.listeners[event] = []
this.listeners[event].push(handler)
}
removeEventListener() {
// no-op for tests
}
play() {
this.paused = false
this.emit('play')
return Promise.resolve()
}
pause() {
this.paused = true
this.emit('pause')
}
private emit(event: string) {
const handlers = this.listeners[event] || []
handlers.forEach(h => h())
}
// Helper to simulate events in tests
simulateEvent(event: string) {
this.emit(event)
}
}
vi.stubGlobal('Audio', MockAudio)
describe('useAudioPlayer', () => {
beforeEach(() => {
// Reset singleton state by stopping any active playback
const player = useAudioPlayer()
player.stop()
})
it('returns all expected properties', () => {
const player = useAudioPlayer()
expect(player.play).toBeTypeOf('function')
expect(player.pause).toBeTypeOf('function')
expect(player.seek).toBeTypeOf('function')
expect(player.stop).toBeTypeOf('function')
expect(player.playing).toBeDefined()
expect(player.currentName).toBeDefined()
expect(player.currentTime).toBeDefined()
expect(player.duration).toBeDefined()
expect(player.progress).toBeDefined()
expect(player.currentSrc).toBeDefined()
expect(player.error).toBeDefined()
})
it('starts in stopped state', () => {
const player = useAudioPlayer()
expect(player.playing.value).toBe(false)
expect(player.currentSrc.value).toBeNull()
expect(player.currentName.value).toBe('')
})
it('play sets playing state and current source', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test Track')
expect(player.playing.value).toBe(true)
expect(player.currentSrc.value).toBe('/audio/test.mp3')
expect(player.currentName.value).toBe('Test Track')
})
it('play toggles pause when same source is playing', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
expect(player.playing.value).toBe(true)
// Play same source again — should pause
player.play('/audio/test.mp3', 'Test')
expect(player.playing.value).toBe(false)
})
it('play switches to new source', () => {
const player = useAudioPlayer()
player.play('/audio/first.mp3', 'First')
player.play('/audio/second.mp3', 'Second')
expect(player.currentSrc.value).toBe('/audio/second.mp3')
expect(player.currentName.value).toBe('Second')
})
it('pause pauses playback', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
player.pause()
expect(player.playing.value).toBe(false)
})
it('stop resets all state', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
player.stop()
expect(player.playing.value).toBe(false)
expect(player.currentSrc.value).toBeNull()
expect(player.currentName.value).toBe('')
})
it('progress computes correctly', () => {
const player = useAudioPlayer()
expect(player.progress.value).toBe(0) // duration is 0
player.currentTime.value = 30
player.duration.value = 120
expect(player.progress.value).toBe(25) // 30/120 * 100
})
it('progress is 0 when duration is 0', () => {
const player = useAudioPlayer()
player.duration.value = 0
player.currentTime.value = 10
expect(player.progress.value).toBe(0)
})
it('shared state across multiple useAudioPlayer calls', () => {
const p1 = useAudioPlayer()
const p2 = useAudioPlayer()
p1.play('/audio/shared.mp3', 'Shared')
expect(p2.currentSrc.value).toBe('/audio/shared.mp3')
expect(p2.playing.value).toBe(true)
})
})
@@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
describe('getFileCategory', () => {
it('returns folder for directories', () => {
expect(getFileCategory('', true)).toBe('folder')
expect(getFileCategory('jpg', true)).toBe('folder')
})
it('identifies image extensions', () => {
expect(getFileCategory('jpg', false)).toBe('image')
expect(getFileCategory('jpeg', false)).toBe('image')
expect(getFileCategory('png', false)).toBe('image')
expect(getFileCategory('gif', false)).toBe('image')
expect(getFileCategory('webp', false)).toBe('image')
expect(getFileCategory('svg', false)).toBe('image')
expect(getFileCategory('bmp', false)).toBe('image')
expect(getFileCategory('ico', false)).toBe('image')
})
it('identifies audio extensions', () => {
expect(getFileCategory('mp3', false)).toBe('audio')
expect(getFileCategory('flac', false)).toBe('audio')
expect(getFileCategory('wav', false)).toBe('audio')
expect(getFileCategory('ogg', false)).toBe('audio')
expect(getFileCategory('aac', false)).toBe('audio')
expect(getFileCategory('m4a', false)).toBe('audio')
})
it('identifies video extensions', () => {
expect(getFileCategory('mp4', false)).toBe('video')
expect(getFileCategory('mkv', false)).toBe('video')
expect(getFileCategory('avi', false)).toBe('video')
expect(getFileCategory('mov', false)).toBe('video')
expect(getFileCategory('webm', false)).toBe('video')
})
it('identifies document extensions', () => {
expect(getFileCategory('pdf', false)).toBe('document')
expect(getFileCategory('doc', false)).toBe('document')
expect(getFileCategory('docx', false)).toBe('document')
expect(getFileCategory('txt', false)).toBe('document')
expect(getFileCategory('md', false)).toBe('document')
})
it('identifies spreadsheet extensions', () => {
expect(getFileCategory('xls', false)).toBe('spreadsheet')
expect(getFileCategory('xlsx', false)).toBe('spreadsheet')
expect(getFileCategory('csv', false)).toBe('spreadsheet')
expect(getFileCategory('ods', false)).toBe('spreadsheet')
})
it('identifies archive extensions', () => {
expect(getFileCategory('zip', false)).toBe('archive')
expect(getFileCategory('tar', false)).toBe('archive')
expect(getFileCategory('gz', false)).toBe('archive')
expect(getFileCategory('rar', false)).toBe('archive')
expect(getFileCategory('7z', false)).toBe('archive')
})
it('returns file for unknown extensions', () => {
expect(getFileCategory('xyz', false)).toBe('file')
expect(getFileCategory('', false)).toBe('file')
expect(getFileCategory('bin', false)).toBe('file')
})
})
describe('useFileType', () => {
it('returns correct category and computed values for an image', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
expect(result.isImage.value).toBe(true)
expect(result.isAudio.value).toBe(false)
expect(result.isVideo.value).toBe(false)
expect(result.iconColor.value).toBe('text-blue-400')
expect(result.badgeLabel.value).toBe('Image')
})
it('returns correct values for audio', () => {
const ext = ref('mp3')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('audio')
expect(result.isAudio.value).toBe(true)
expect(result.isImage.value).toBe(false)
expect(result.iconColor.value).toBe('text-orange-400')
expect(result.badgeLabel.value).toBe('Audio')
})
it('returns correct values for video', () => {
const ext = ref('mp4')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('video')
expect(result.isVideo.value).toBe(true)
expect(result.iconColor.value).toBe('text-purple-400')
})
it('returns folder when isDir is true', () => {
const ext = ref('jpg')
const isDir = ref(true)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('folder')
expect(result.isImage.value).toBe(false)
expect(result.iconColor.value).toBe('text-amber-400')
expect(result.badgeLabel.value).toBe('Folder')
})
it('reacts to ref changes', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
ext.value = 'mp3'
expect(result.category.value).toBe('audio')
expect(result.isAudio.value).toBe(true)
expect(result.isImage.value).toBe(false)
})
it('provides icon paths for each category', () => {
const ext = ref('pdf')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.iconPaths.value).toBeDefined()
expect(result.iconPaths.value.length).toBeGreaterThan(0)
})
it('provides badge class for each category', () => {
const ext = ref('zip')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.badgeClass.value).toContain('bg-yellow')
})
})
describe('formatSize', () => {
it('formats 0 bytes', () => {
expect(formatSize(0)).toBe('0 B')
})
it('formats bytes', () => {
expect(formatSize(500)).toBe('500 B')
})
it('formats kilobytes', () => {
expect(formatSize(1024)).toBe('1.0 KB')
expect(formatSize(1536)).toBe('1.5 KB')
})
it('formats megabytes', () => {
expect(formatSize(1048576)).toBe('1.0 MB')
})
it('formats gigabytes', () => {
expect(formatSize(1073741824)).toBe('1.0 GB')
})
it('formats terabytes', () => {
expect(formatSize(1099511627776)).toBe('1.0 TB')
})
})
describe('formatDate', () => {
it('returns "Just now" for very recent dates', () => {
const now = new Date().toISOString()
expect(formatDate(now)).toBe('Just now')
})
it('returns minutes ago for recent dates', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
expect(formatDate(fiveMinAgo)).toBe('5m ago')
})
it('returns hours ago for dates within 24h', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
expect(formatDate(threeHoursAgo)).toBe('3h ago')
})
it('returns days ago for dates within a week', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
expect(formatDate(twoDaysAgo)).toBe('2d ago')
})
it('returns formatted date for older dates', () => {
const oldDate = new Date('2025-01-15').toISOString()
const result = formatDate(oldDate)
// Should be a locale date string, not a relative time
expect(result).toMatch(/\d/)
expect(result).not.toContain('ago')
})
})
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useMarketplaceApp } from '../useMarketplaceApp'
describe('useMarketplaceApp', () => {
beforeEach(() => {
const { clearCurrentApp } = useMarketplaceApp()
clearCurrentApp()
})
it('getCurrentApp returns null initially', () => {
const { getCurrentApp } = useMarketplaceApp()
expect(getCurrentApp()).toBeNull()
})
it('setCurrentApp stores a full app', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'bitcoin',
title: 'Bitcoin Core',
version: '25.0',
icon: '/icons/btc.png',
category: 'Finance',
description: 'Bitcoin node',
author: 'Satoshi',
source: 'github',
manifestUrl: 'https://example.com/manifest',
url: 'https://example.com',
repoUrl: 'https://github.com/bitcoin/bitcoin',
s9pkUrl: '',
dockerImage: 'bitcoin:25.0',
})
const app = getCurrentApp()
expect(app).not.toBeNull()
expect(app!.id).toBe('bitcoin')
expect(app!.title).toBe('Bitcoin Core')
expect(app!.version).toBe('25.0')
})
it('setCurrentApp with partial app fills defaults', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'lnd' })
const app = getCurrentApp()
expect(app).not.toBeNull()
expect(app!.id).toBe('lnd')
expect(app!.title).toBe('')
expect(app!.version).toBe('')
expect(app!.icon).toBe('')
expect(app!.dockerImage).toBe('')
})
it('manifestUrl falls back to s9pkUrl then url', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'test', s9pkUrl: 'https://s9pk.example.com/app.s9pk' })
const app = getCurrentApp()
expect(app!.manifestUrl).toBe('https://s9pk.example.com/app.s9pk')
expect(app!.url).toBe('https://s9pk.example.com/app.s9pk')
})
it('url falls back to s9pkUrl then manifestUrl', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'test', manifestUrl: 'https://manifest.example.com' })
const app = getCurrentApp()
expect(app!.url).toBe('https://manifest.example.com')
})
it('clearCurrentApp sets app to null', () => {
const { setCurrentApp, clearCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'bitcoin' })
expect(getCurrentApp()).not.toBeNull()
clearCurrentApp()
expect(getCurrentApp()).toBeNull()
})
it('shared state across multiple useMarketplaceApp calls', () => {
const instance1 = useMarketplaceApp()
const instance2 = useMarketplaceApp()
instance1.setCurrentApp({ id: 'mempool', title: 'Mempool' })
const app = instance2.getCurrentApp()
expect(app!.id).toBe('mempool')
expect(app!.title).toBe('Mempool')
})
it('handles description as object', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'test',
description: { short: 'Short desc', long: 'Long description' },
})
const app = getCurrentApp()
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
})
})
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
getReceivedMessages: vi.fn(),
},
}))
import { useMessageToast } from '../useMessageToast'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('useMessageToast', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset shared singleton state
const toast = useMessageToast()
toast.stopPolling()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 0
toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '' }
})
afterEach(() => {
const toast = useMessageToast()
toast.stopPolling()
vi.useRealTimers()
})
it('starts with empty state', () => {
const toast = useMessageToast()
expect(toast.receivedMessages.value).toEqual([])
expect(toast.lastMessageCount.value).toBe(0)
expect(toast.loadingMessages.value).toBe(false)
expect(toast.toastMessage.value.show).toBe(false)
expect(toast.unreadCount.value).toBe(0)
})
it('loadReceivedMessages fetches and stores messages', async () => {
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
],
})
const toast = useMessageToast()
await toast.loadReceivedMessages()
expect(toast.receivedMessages.value.length).toBe(1)
expect(toast.lastMessageCount.value).toBe(1)
expect(toast.loadingMessages.value).toBe(false)
})
it('does not show toast on initial load', async () => {
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
})
const toast = useMessageToast()
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(false)
})
it('shows toast when new messages arrive after initial load', async () => {
const toast = useMessageToast()
// Initial load
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
})
await toast.loadReceivedMessages()
// New message arrives
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
],
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(true)
expect(toast.toastMessage.value.text).toBe('Second')
})
it('shows count for multiple new messages', async () => {
const toast = useMessageToast()
// Initial load
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
})
await toast.loadReceivedMessages()
// Multiple new messages
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
],
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(true)
expect(toast.toastMessage.value.text).toBe('2 new messages')
})
it('unreadCount reflects difference', async () => {
const toast = useMessageToast()
toast.receivedMessages.value = [
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
]
toast.lastMessageCount.value = 1
expect(toast.unreadCount.value).toBe(1)
})
it('unreadCount is never negative', () => {
const toast = useMessageToast()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 5
expect(toast.unreadCount.value).toBe(0)
})
it('markAsRead syncs lastMessageCount', () => {
const toast = useMessageToast()
toast.receivedMessages.value = [
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
]
toast.lastMessageCount.value = 0
toast.markAsRead()
expect(toast.lastMessageCount.value).toBe(2)
expect(toast.unreadCount.value).toBe(0)
})
it('dismissToastAndOpenMessages clears toast and navigates', () => {
const toast = useMessageToast()
toast.toastMessage.value = { show: true, text: 'New message' }
toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false)
expect(mockPush).toHaveBeenCalledWith({ path: '/dashboard/web5', query: { tab: 'messages' } })
})
it('stops polling on 401 error', async () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
toast.startPolling()
// Wait for initial load triggered by startPolling
await vi.advanceTimersByTimeAsync(0)
// Polling should have stopped, so advancing time should NOT call again
vi.clearAllMocks()
await vi.advanceTimersByTimeAsync(60000)
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
})
it('startPolling does not create duplicate timers', () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
toast.startPolling()
toast.startPolling()
toast.startPolling()
// Should only have one timer — verify by stopping and checking no more calls
toast.stopPolling()
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useToast } from '../useToast'
describe('useToast', () => {
beforeEach(() => {
vi.useFakeTimers()
// Get a fresh toast instance and clear any leftover state
const { toasts, dismiss } = useToast()
// Dismiss all existing toasts
for (const t of [...toasts.value]) {
dismiss(t.id)
}
vi.advanceTimersByTime(500)
})
afterEach(() => {
vi.useRealTimers()
})
it('creates a success toast', () => {
const { success, toasts } = useToast()
success('Operation complete')
expect(toasts.value.length).toBeGreaterThanOrEqual(1)
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('Operation complete')
expect(toast.variant).toBe('success')
expect(toast.dismissing).toBe(false)
})
it('creates an error toast', () => {
const { error, toasts } = useToast()
error('Something went wrong')
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('Something went wrong')
expect(toast.variant).toBe('error')
})
it('creates an info toast', () => {
const { info, toasts } = useToast()
info('FYI: Node syncing')
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('FYI: Node syncing')
expect(toast.variant).toBe('info')
})
it('auto-dismisses toast after duration', () => {
const { success, toasts } = useToast()
success('Will auto-dismiss')
const toast = toasts.value[toasts.value.length - 1]!
const toastId = toast.id
expect(toasts.value.some((t) => t.id === toastId)).toBe(true)
// After 3000ms, the toast should start dismissing
vi.advanceTimersByTime(3000)
const dismissingToast = toasts.value.find((t) => t.id === toastId)
if (dismissingToast) {
expect(dismissingToast.dismissing).toBe(true)
}
// After another 300ms, the toast should be fully removed
vi.advanceTimersByTime(300)
expect(toasts.value.some((t) => t.id === toastId)).toBe(false)
})
it('dismiss marks toast as dismissing then removes it', () => {
const { info, toasts, dismiss } = useToast()
info('Dismissable')
const toast = toasts.value[toasts.value.length - 1]!
dismiss(toast.id)
// Should be marked as dismissing
const found = toasts.value.find((t) => t.id === toast.id)
if (found) {
expect(found.dismissing).toBe(true)
}
// After 300ms animation delay, should be removed
vi.advanceTimersByTime(300)
expect(toasts.value.some((t) => t.id === toast.id)).toBe(false)
})
it('dismiss is a no-op for nonexistent toast ID', () => {
const { dismiss, toasts } = useToast()
const countBefore = toasts.value.length
dismiss(999999)
expect(toasts.value.length).toBe(countBefore)
})
it('each toast gets a unique ID', () => {
const { info, toasts } = useToast()
info('First')
info('Second')
info('Third')
const ids = toasts.value.slice(-3).map((t) => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
it('caps visible toasts at 5', () => {
const { info, toasts } = useToast()
for (let i = 0; i < 7; i++) {
info(`Toast ${i}`)
}
expect(toasts.value.length).toBeLessThanOrEqual(5)
})
it('toasts ref is readonly', () => {
const { toasts } = useToast()
// The readonly wrapper prevents direct mutation
expect(typeof toasts.value).toBe('object')
})
})
+18 -1
View File
@@ -93,7 +93,7 @@ export function stopSynthwave() {
if (introAudio) {
if (introGain && audioContext) {
const t = audioContext.currentTime
introGain.gain.setValueAtTime(1, t)
introGain.gain.setValueAtTime(introGain.gain.value, t)
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
}
setTimeout(() => {
@@ -104,6 +104,23 @@ export function stopSynthwave() {
}
}
/** Stop ALL login audio and close AudioContext. Call on route change to dashboard. */
export function stopAllAudio() {
// Stop synthwave loop
if (introAudio) {
introAudio.pause()
introAudio = null
introGain = null
}
// Stop intro typing
stopIntroTyping()
// Close AudioContext to kill any lingering BufferSource nodes (playLoopStart)
if (audioContext) {
audioContext.close().catch(() => {})
audioContext = null
}
}
/** Pop sound - plays when intro initiator (tap to start) is pressed */
export function playPop() {
const audio = new Audio('/assets/audio/pop.mp3')
@@ -14,6 +14,8 @@ export interface MarketplaceAppInfo {
repoUrl: string
s9pkUrl: string
dockerImage: string
/** External web URL for iframe-based web apps (no container needed) */
webUrl?: string
}
// Simple in-memory store for the current marketplace app
@@ -36,6 +38,7 @@ export function useMarketplaceApp() {
repoUrl: app.repoUrl ?? '',
s9pkUrl: app.s9pkUrl ?? '',
dockerImage: app.dockerImage ?? '',
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export function useMessageToast() {
stopPolling()
return
}
console.error('Failed to load messages:', e)
if (import.meta.env.DEV) console.error('Failed to load messages:', e)
} finally {
loadingMessages.value = false
}
+10 -3
View File
@@ -314,7 +314,7 @@
"modeEasyDesc": "Goal-based interface. Choose what you want to do, and the system handles the rest.",
"modePro": "Pro",
"modeProDesc": "Full control over all services. Configure everything manually with all technical details.",
"modeChat": "Chat",
"modeChat": "AIUI",
"modeChatDesc": "Conversational AI interface. Manage your node through natural language. Coming soon."
},
"marketplace": {
@@ -353,8 +353,8 @@
"closeAssistant": "Close AI Assistant",
"loadingAssistant": "Loading AI assistant...",
"aiAssistant": "AI Assistant",
"notConfigured": "AI Assistant is not yet configured on this node.",
"deployCta": "Deploy the AIUI app from the App Store to enable this feature."
"notConfigured": "AI Assistant needs to be enabled before use.",
"deployCta": "Go to Settings to configure your AI provider API key, then return here to start chatting."
},
"web5": {
"title": "Web5",
@@ -682,6 +682,13 @@
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
"rollbackFailed": "Rollback failed."
},
"kiosk": {
"pressEsc": "Press ESC to exit",
"online": "Online",
"offline": "Offline",
"escHint": "Press ESC to exit apps",
"navHint": "Use arrow keys to navigate"
},
"kioskRecovery": {
"title": "Archipelago Recovery",
"subtitle": "Kiosk failsafe — no authentication required",
@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createRouter, createWebHistory } from 'vue-router'
import { setActivePinia, createPinia } from 'pinia'
import { defineComponent } from 'vue'
// Mock the app store module
const mockStore = {
isAuthenticated: false,
isConnected: false,
isReconnecting: false,
needsSessionValidation: vi.fn().mockReturnValue(false),
checkSession: vi.fn().mockResolvedValue(false),
connectWebSocket: vi.fn().mockResolvedValue(undefined),
}
vi.mock('@/stores/app', () => ({
useAppStore: () => mockStore,
}))
const Stub = defineComponent({ template: '<div />' })
function createTestRouter() {
return createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: Stub,
meta: { public: true },
children: [
{ path: '', component: Stub },
{ path: 'login', name: 'login', component: Stub },
{ path: 'onboarding/intro', name: 'onboarding-intro', component: Stub },
{ path: 'onboarding/options', name: 'onboarding-options', component: Stub },
{ path: 'onboarding/path', name: 'onboarding-path', component: Stub },
{ path: 'onboarding/did', name: 'onboarding-did', component: Stub },
{ path: 'onboarding/identity', name: 'onboarding-identity', component: Stub },
{ path: 'onboarding/backup', name: 'onboarding-backup', component: Stub },
{ path: 'onboarding/verify', name: 'onboarding-verify', component: Stub },
{ path: 'onboarding/done', name: 'onboarding-done', component: Stub },
],
},
{
path: '/dashboard',
component: Stub,
children: [
{ path: '', name: 'home', component: Stub },
{ path: 'apps', name: 'apps', component: Stub },
{ path: 'settings', name: 'settings', component: Stub },
],
},
],
})
}
describe('Onboarding Routing Flow', () => {
let router: ReturnType<typeof createTestRouter>
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockStore.isAuthenticated = false
mockStore.isConnected = false
mockStore.isReconnecting = false
mockStore.needsSessionValidation.mockReturnValue(false)
mockStore.checkSession.mockResolvedValue(false)
router = createTestRouter()
// Add the same beforeEach guard as the real router
router.beforeEach(async (to) => {
const isPublic = to.meta.public
if (isPublic) {
if (to.path === '/login' && mockStore.isAuthenticated) {
if (mockStore.needsSessionValidation()) {
return true
}
return { name: 'home' }
}
return true
}
if (mockStore.needsSessionValidation()) {
mockStore.checkSession()
return true
}
if (!mockStore.isAuthenticated) {
const hasSession = await mockStore.checkSession()
if (hasSession) return true
return '/login'
}
if (!mockStore.isConnected && !mockStore.isReconnecting) {
mockStore.connectWebSocket()
}
return true
})
})
describe('unauthenticated users', () => {
it('redirects to /login when accessing /dashboard, not to /onboarding', async () => {
mockStore.checkSession.mockResolvedValue(false)
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.path).not.toContain('/onboarding')
})
it('redirects to /login when accessing /dashboard/apps', async () => {
mockStore.checkSession.mockResolvedValue(false)
await router.push('/dashboard/apps')
expect(router.currentRoute.value.path).toBe('/login')
})
it('redirects to /login when accessing /dashboard/settings', async () => {
mockStore.checkSession.mockResolvedValue(false)
await router.push('/dashboard/settings')
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('onboarding routes are accessible when authenticated', () => {
beforeEach(() => {
mockStore.isAuthenticated = true
})
it('allows access to /onboarding/intro', async () => {
await router.push('/onboarding/intro')
expect(router.currentRoute.value.path).toBe('/onboarding/intro')
expect(router.currentRoute.value.name).toBe('onboarding-intro')
})
it('allows access to /onboarding/options', async () => {
await router.push('/onboarding/options')
expect(router.currentRoute.value.path).toBe('/onboarding/options')
expect(router.currentRoute.value.name).toBe('onboarding-options')
})
it('allows access to /onboarding/path', async () => {
await router.push('/onboarding/path')
expect(router.currentRoute.value.path).toBe('/onboarding/path')
expect(router.currentRoute.value.name).toBe('onboarding-path')
})
})
describe('OnboardingDid route', () => {
it('is accessible when authenticated', async () => {
mockStore.isAuthenticated = true
await router.push('/onboarding/did')
expect(router.currentRoute.value.path).toBe('/onboarding/did')
expect(router.currentRoute.value.name).toBe('onboarding-did')
})
it('is accessible when unauthenticated (public route)', async () => {
mockStore.isAuthenticated = false
await router.push('/onboarding/did')
expect(router.currentRoute.value.path).toBe('/onboarding/did')
expect(router.currentRoute.value.name).toBe('onboarding-did')
})
})
describe('post-onboarding navigation to dashboard', () => {
it('allows authenticated users to navigate from onboarding to /dashboard', async () => {
mockStore.isAuthenticated = true
// Start at onboarding done page
await router.push('/onboarding/done')
expect(router.currentRoute.value.path).toBe('/onboarding/done')
// Navigate to dashboard (simulating post-onboarding completion)
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/dashboard')
expect(router.currentRoute.value.name).toBe('home')
})
it('allows authenticated users to navigate from onboarding/did to /dashboard', async () => {
mockStore.isAuthenticated = true
await router.push('/onboarding/did')
expect(router.currentRoute.value.path).toBe('/onboarding/did')
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/dashboard')
expect(router.currentRoute.value.name).toBe('home')
})
it('allows navigation through the full onboarding sequence', async () => {
mockStore.isAuthenticated = true
await router.push('/onboarding/intro')
expect(router.currentRoute.value.name).toBe('onboarding-intro')
await router.push('/onboarding/path')
expect(router.currentRoute.value.name).toBe('onboarding-path')
await router.push('/onboarding/did')
expect(router.currentRoute.value.name).toBe('onboarding-did')
await router.push('/onboarding/identity')
expect(router.currentRoute.value.name).toBe('onboarding-identity')
await router.push('/onboarding/backup')
expect(router.currentRoute.value.name).toBe('onboarding-backup')
await router.push('/onboarding/verify')
expect(router.currentRoute.value.name).toBe('onboarding-verify')
await router.push('/onboarding/done')
expect(router.currentRoute.value.name).toBe('onboarding-done')
})
})
describe('dashboard guard blocks unauthenticated access after onboarding', () => {
it('prevents unauthenticated navigation from onboarding/done to /dashboard', async () => {
// Start at onboarding done (public route, accessible without auth)
await router.push('/onboarding/done')
expect(router.currentRoute.value.path).toBe('/onboarding/done')
// Try to navigate to dashboard without auth
mockStore.checkSession.mockResolvedValue(false)
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/login')
})
it('prevents unauthenticated access to /dashboard even with stale session', async () => {
mockStore.isAuthenticated = false
mockStore.needsSessionValidation.mockReturnValue(false)
mockStore.checkSession.mockResolvedValue(false)
await router.push('/dashboard/apps')
expect(router.currentRoute.value.path).toBe('/login')
})
it('allows dashboard access when session check succeeds', async () => {
mockStore.isAuthenticated = false
mockStore.checkSession.mockResolvedValue(true)
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/dashboard')
})
})
})
+41 -1
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import { nextTick } from 'vue'
import { useAppStore } from '../stores/app'
import { stopAllAudio } from '../composables/useLoginSounds'
const router = createRouter({
history: createWebHistory(),
@@ -61,6 +62,18 @@ const router = createRouter({
},
],
},
{
path: '/recovery',
name: 'recovery',
component: () => import('../views/KioskRecovery.vue'),
meta: { public: true },
},
{
path: '/kiosk',
name: 'kiosk',
component: () => import('../views/Kiosk.vue'),
meta: { public: true },
},
{
path: '/dashboard',
component: () => import('../views/Dashboard.vue'),
@@ -110,16 +123,36 @@ const router = createRouter({
name: 'server',
component: () => import('../views/Server.vue'),
},
{
path: 'monitoring',
name: 'monitoring',
component: () => import('../views/Monitoring.vue'),
},
{
path: 'server/federation',
name: 'federation',
component: () => import('../views/Federation.vue'),
},
{
path: 'web5',
name: 'web5',
component: () => import('../views/Web5.vue'),
},
{
path: 'web5/credentials',
name: 'credentials',
component: () => import('../views/Credentials.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('../views/Settings.vue'),
},
{
path: 'settings/update',
name: 'system-update',
component: () => import('../views/SystemUpdate.vue'),
},
{
path: 'goals/:goalId',
name: 'goal-detail',
@@ -216,13 +249,20 @@ router.beforeEach(async (to, _from, next) => {
// Validated and authenticated - ensure WebSocket is connected
if (!store.isConnected && !store.isReconnecting) {
store.connectWebSocket().catch((err) => {
console.warn('[Router] WebSocket connection failed:', err)
if (import.meta.env.DEV) console.warn('[Router] WebSocket connection failed:', err)
})
}
next()
})
// Stop all login/splash audio when entering the dashboard
router.afterEach((to, from) => {
if (to.path.startsWith('/dashboard') && !from.path.startsWith('/dashboard')) {
stopAllAudio()
}
})
// Focus Home nav item for gamepad when landing on dashboard home (e.g. after login)
router.afterEach((to) => {
if (to.path === '/dashboard' || to.path === '/dashboard/') {
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '../aiPermissions'
const STORAGE_KEY = 'archipelago-ai-permissions'
describe('useAIPermissionsStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.clearAllMocks()
})
it('starts with empty permissions when no localStorage', () => {
const store = useAIPermissionsStore()
expect(store.enabled.size).toBe(0)
expect(store.noneEnabled).toBe(true)
expect(store.allEnabled).toBe(false)
})
it('loads valid categories from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'system']))
setActivePinia(createPinia())
const store = useAIPermissionsStore()
expect(store.isEnabled('apps')).toBe(true)
expect(store.isEnabled('system')).toBe(true)
expect(store.enabled.size).toBe(2)
})
it('filters invalid categories from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'invalid-category', 'system']))
setActivePinia(createPinia())
const store = useAIPermissionsStore()
expect(store.enabled.size).toBe(2)
expect(store.isEnabled('apps')).toBe(true)
expect(store.isEnabled('system')).toBe(true)
})
it('handles corrupt localStorage gracefully', () => {
localStorage.setItem(STORAGE_KEY, 'not-valid-json{')
setActivePinia(createPinia())
const store = useAIPermissionsStore()
expect(store.enabled.size).toBe(0)
})
it('toggle adds a category', () => {
const store = useAIPermissionsStore()
store.toggle('bitcoin')
expect(store.isEnabled('bitcoin')).toBe(true)
})
it('toggle removes an enabled category', () => {
const store = useAIPermissionsStore()
store.toggle('bitcoin')
store.toggle('bitcoin')
expect(store.isEnabled('bitcoin')).toBe(false)
})
it('toggle persists to localStorage', () => {
const store = useAIPermissionsStore()
store.toggle('apps')
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
expect(stored).toContain('apps')
})
it('enableAll enables all categories', () => {
const store = useAIPermissionsStore()
store.enableAll()
expect(store.allEnabled).toBe(true)
expect(store.enabled.size).toBe(AI_PERMISSION_CATEGORIES.length)
for (const cat of AI_PERMISSION_CATEGORIES) {
expect(store.isEnabled(cat.id)).toBe(true)
}
})
it('disableAll disables all categories', () => {
const store = useAIPermissionsStore()
store.enableAll()
store.disableAll()
expect(store.noneEnabled).toBe(true)
expect(store.enabled.size).toBe(0)
})
it('enabledCategories returns array of enabled IDs', () => {
const store = useAIPermissionsStore()
store.toggle('apps')
store.toggle('network')
expect(store.enabledCategories).toContain('apps')
expect(store.enabledCategories).toContain('network')
expect(store.enabledCategories.length).toBe(2)
})
it('AI_PERMISSION_CATEGORIES has 10 categories', () => {
expect(AI_PERMISSION_CATEGORIES.length).toBe(10)
})
it('all categories have required fields', () => {
for (const cat of AI_PERMISSION_CATEGORIES) {
expect(cat.id).toBeTruthy()
expect(cat.label).toBeTruthy()
expect(cat.description).toBeTruthy()
expect(cat.icon).toBeTruthy()
expect(cat.group).toBeTruthy()
}
})
})
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAppLauncherStore } from '../appLauncher'
// Mock window.open for new-tab tests
const mockWindowOpen = vi.fn()
vi.stubGlobal('open', mockWindowOpen)
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('opens an app in the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8080', title: 'Mempool' })
expect(store.isOpen).toBe(true)
expect(store.url).toBe('http://192.168.1.228:8080')
expect(store.title).toBe('Mempool')
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('opens BTCPay (port 23000) in a new tab due to X-Frame-Options', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:23000',
'_blank',
'noopener,noreferrer',
)
})
it('opens Home Assistant (port 8123) in a new tab', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalled()
})
it('opens Grafana (port 3000) in a new tab', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalled()
})
it('opens in new tab when openInNewTab flag is set', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8080', title: 'Mempool', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8080',
'_blank',
'noopener,noreferrer',
)
})
it('rewrites URL to proxy path on HTTPS for same-host apps', () => {
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' })
expect(store.isOpen).toBe(true)
expect(store.url).toBe('https://192.168.1.228/app/filebrowser/')
})
it('does not rewrite URL on HTTP (no mixed content)', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
expect(store.url).toBe('http://192.168.1.228:8083')
})
it('does not rewrite URL for different hosts', () => {
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:8083', title: 'Remote FileBrowser' })
// Different host — no proxy rewriting
expect(store.url).toBe('http://192.168.1.100:8083')
})
it('close resets state', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8080', title: 'Mempool' })
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:8080', title: 'Mempool' })
store.close()
expect(store.isOpen).toBe(false)
expect(store.url).toBe('')
// requestAnimationFrame fires the focus restore callback
vi.runAllTimers()
vi.useRealTimers()
})
})
+65
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)
})
})
+233
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()
})
})
@@ -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)
})
})
+227
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')
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock the nav sounds module
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
import { useSpotlightStore } from '../spotlight'
import { playNavSound } from '@/composables/useNavSounds'
const mockedPlayNavSound = vi.mocked(playNavSound)
describe('useSpotlightStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
})
it('starts closed with default state', () => {
const store = useSpotlightStore()
expect(store.isOpen).toBe(false)
expect(store.selectedIndex).toBe(0)
expect(store.recentItems).toEqual([])
})
it('open sets isOpen to true and plays sound', () => {
const store = useSpotlightStore()
store.open()
expect(store.isOpen).toBe(true)
expect(store.selectedIndex).toBe(0)
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
})
it('close sets isOpen to false and resets index', () => {
const store = useSpotlightStore()
store.open()
store.close()
expect(store.isOpen).toBe(false)
expect(store.selectedIndex).toBe(0)
})
it('toggle opens when closed', () => {
const store = useSpotlightStore()
store.toggle()
expect(store.isOpen).toBe(true)
})
it('toggle closes when open', () => {
const store = useSpotlightStore()
store.open()
store.toggle()
expect(store.isOpen).toBe(false)
})
it('setSelectedIndex updates the selected index', () => {
const store = useSpotlightStore()
store.setSelectedIndex(3)
expect(store.selectedIndex).toBe(3)
})
describe('recent items', () => {
it('addRecentItem adds item with timestamp', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
expect(store.recentItems).toHaveLength(1)
expect(store.recentItems[0]!.id).toBe('home')
expect(store.recentItems[0]!.label).toBe('Home')
expect(store.recentItems[0]!.timestamp).toBeGreaterThan(0)
})
it('addRecentItem deduplicates by id and type', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
store.addRecentItem({ id: 'home', label: 'Home Updated', path: '/dashboard', type: 'navigate' })
expect(store.recentItems).toHaveLength(1)
expect(store.recentItems[0]!.label).toBe('Home Updated')
})
it('addRecentItem keeps different types with same id', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (navigate)', type: 'navigate' })
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (action)', type: 'action' })
expect(store.recentItems).toHaveLength(2)
})
it('addRecentItem caps at 8 items', () => {
const store = useSpotlightStore()
for (let i = 0; i < 10; i++) {
store.addRecentItem({ id: `item-${i}`, label: `Item ${i}`, type: 'navigate' })
}
expect(store.recentItems).toHaveLength(8)
// Most recent should be first
expect(store.recentItems[0]!.id).toBe('item-9')
})
it('addRecentItem persists to localStorage', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate' })
const stored = JSON.parse(localStorage.getItem('archipelago-spotlight-recent')!)
expect(stored).toHaveLength(1)
expect(stored[0].id).toBe('apps')
})
it('loadRecentItems reads from localStorage', () => {
const saved = [
{ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate', timestamp: 1000 },
{ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate', timestamp: 2000 },
]
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
const store = useSpotlightStore()
store.loadRecentItems()
expect(store.recentItems).toHaveLength(2)
expect(store.recentItems[0]!.id).toBe('home')
})
it('loadRecentItems handles corrupt localStorage', () => {
localStorage.setItem('archipelago-spotlight-recent', 'not-json{{{')
const store = useSpotlightStore()
store.loadRecentItems()
expect(store.recentItems).toEqual([])
})
it('loadRecentItems handles empty localStorage', () => {
const store = useSpotlightStore()
store.loadRecentItems()
expect(store.recentItems).toEqual([])
})
it('open calls loadRecentItems', () => {
const saved = [
{ id: 'test', label: 'Test', type: 'navigate', timestamp: 1000 },
]
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
const store = useSpotlightStore()
store.open()
expect(store.recentItems).toHaveLength(1)
})
})
describe('help modal', () => {
it('showHelpModal opens the modal with content', () => {
const store = useSpotlightStore()
store.showHelpModal({
title: 'What is Bitcoin?',
content: 'A peer-to-peer electronic cash system.',
relatedPath: '/apps/bitcoin',
})
expect(store.helpModal.show).toBe(true)
expect(store.helpModal.title).toBe('What is Bitcoin?')
expect(store.helpModal.content).toBe('A peer-to-peer electronic cash system.')
expect(store.helpModal.relatedPath).toBe('/apps/bitcoin')
})
it('closeHelpModal closes the modal', () => {
const store = useSpotlightStore()
store.showHelpModal({ title: 'Test', content: 'Content' })
store.closeHelpModal()
expect(store.helpModal.show).toBe(false)
})
})
})
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUIModeStore } from '../uiMode'
describe('useUIModeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('defaults to gamer mode when no stored value', () => {
const store = useUIModeStore()
expect(store.mode).toBe('gamer')
expect(store.isGamer).toBe(true)
expect(store.isEasy).toBe(false)
expect(store.isChat).toBe(false)
})
it('loads stored mode from localStorage', () => {
localStorage.setItem('archipelago-ui-mode', 'easy')
const store = useUIModeStore()
expect(store.mode).toBe('easy')
expect(store.isEasy).toBe(true)
})
it('ignores invalid localStorage values', () => {
localStorage.setItem('archipelago-ui-mode', 'invalid-mode')
const store = useUIModeStore()
expect(store.mode).toBe('gamer')
})
it('setMode updates mode and persists', () => {
const store = useUIModeStore()
store.setMode('easy')
expect(store.mode).toBe('easy')
expect(store.isEasy).toBe(true)
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
})
it('setMode to chat mode', () => {
const store = useUIModeStore()
store.setMode('chat')
expect(store.mode).toBe('chat')
expect(store.isChat).toBe(true)
expect(store.isGamer).toBe(false)
})
it('cycleMode cycles between easy and gamer', () => {
const store = useUIModeStore()
// Start at gamer
expect(store.mode).toBe('gamer')
// Cycle to easy
const next1 = store.cycleMode()
expect(next1).toBe('easy')
expect(store.mode).toBe('easy')
// Cycle back to gamer (wraps after easy since order is [easy, gamer])
const next2 = store.cycleMode()
expect(next2).toBe('gamer')
expect(store.mode).toBe('gamer')
})
it('cycleMode from chat wraps to easy', () => {
const store = useUIModeStore()
store.setMode('chat')
const next = store.cycleMode()
// chat is not in the order array, so idx=-1, next = order[0] = easy
expect(next).toBe('easy')
})
it('syncFromBackend updates mode from backend', () => {
const store = useUIModeStore()
store.syncFromBackend('easy')
expect(store.mode).toBe('easy')
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
})
it('syncFromBackend ignores invalid modes', () => {
const store = useUIModeStore()
store.syncFromBackend('invalid' as 'gamer')
expect(store.mode).toBe('gamer') // unchanged
})
it('syncFromBackend ignores undefined', () => {
const store = useUIModeStore()
store.syncFromBackend(undefined)
expect(store.mode).toBe('gamer') // unchanged
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useWeb5BadgeStore } from '../web5Badge'
// Mock rpcClient
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
import { rpcClient } from '@/api/rpc-client'
describe('useWeb5BadgeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('starts with zero pending requests', () => {
const store = useWeb5BadgeStore()
expect(store.pendingRequestCount).toBe(0)
})
it('refresh updates count from API', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
requests: [{ id: '1' }, { id: '2' }, { id: '3' }],
})
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(3)
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'network.list-requests' })
})
it('refresh handles empty requests', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [] })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(0)
})
it('refresh handles null requests gracefully', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: null })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(0)
})
it('refresh handles API error gracefully', async () => {
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('Network error'))
const store = useWeb5BadgeStore()
store.pendingRequestCount = 5 // pre-existing value
await store.refresh()
// Should not throw, count stays at pre-existing value (error swallowed)
expect(store.pendingRequestCount).toBe(5)
})
it('refresh updates count on subsequent calls', async () => {
vi.mocked(rpcClient.call)
.mockResolvedValueOnce({ requests: [{ id: '1' }] })
.mockResolvedValueOnce({ requests: [{ id: '1' }, { id: '2' }] })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(1)
await store.refresh()
expect(store.pendingRequestCount).toBe(2)
})
})
+16 -20
View File
@@ -47,7 +47,7 @@ export const useAppStore = defineStore('app', () => {
// Connect WebSocket in background - don't block login flow
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after login, will retry:', err)
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err)
})
return {}
} catch (err) {
@@ -64,7 +64,7 @@ export const useAppStore = defineStore('app', () => {
localStorage.setItem('neode-auth', 'true')
await initializeData()
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
})
}
@@ -72,7 +72,7 @@ export const useAppStore = defineStore('app', () => {
try {
await rpcClient.logout()
} catch (err) {
console.error('Logout error:', err)
if (import.meta.env.DEV) console.error('Logout error:', err)
} finally {
isAuthenticated.value = false
sessionValidated = false
@@ -87,7 +87,7 @@ export const useAppStore = defineStore('app', () => {
async function connectWebSocket(): Promise<void> {
try {
console.log('[Store] Connecting WebSocket...')
if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...')
isReconnecting.value = true
// Don't create multiple subscriptions - check if already subscribed
@@ -96,20 +96,16 @@ export const useAppStore = defineStore('app', () => {
isWsSubscribed = true
// Listen for connection state changes
wsClient.onConnectionStateChange((connected) => {
console.log('[Store] WebSocket connection state changed:', connected)
isConnected.value = connected
if (!connected) {
isReconnecting.value = true
} else {
isReconnecting.value = false
}
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) {
console.log('[Store] Received initial data from mock backend')
if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend')
data.value = update.data
isConnected.value = true
isReconnecting.value = false
@@ -123,7 +119,7 @@ export const useAppStore = defineStore('app', () => {
// Handle patch updates (both backends)
else if (data.value && update?.patch) {
try {
console.log('[Store] Applying patch at revision', update.rev || 'unknown')
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) {
@@ -131,7 +127,7 @@ export const useAppStore = defineStore('app', () => {
isReconnecting.value = false
}
} catch (err) {
console.error('[Store] Failed to apply WebSocket patch:', err)
if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err)
}
}
})
@@ -140,14 +136,14 @@ export const useAppStore = defineStore('app', () => {
// Now connect (or reconnect if already connected)
// Only attempt to connect if not already connected
if (wsClient.isConnected()) {
console.log('[Store] WebSocket already connected')
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
isConnected.value = true
isReconnecting.value = false
return
}
await wsClient.connect()
console.log('[Store] WebSocket connected')
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
// Connection state will be updated via the callback
if (wsClient.isConnected()) {
@@ -156,7 +152,7 @@ export const useAppStore = defineStore('app', () => {
}
} catch (err) {
console.error('[Store] WebSocket connection failed:', 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
@@ -215,13 +211,13 @@ export const useAppStore = defineStore('app', () => {
await initializeData()
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket reconnection failed, will retry:', err)
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
isReconnecting.value = true
})
return true
} catch (err) {
console.error('[Store] Session check failed:', err)
if (import.meta.env.DEV) console.error('[Store] Session check failed:', err)
localStorage.removeItem('neode-auth')
isAuthenticated.value = false
sessionValidated = false
+23 -2
View File
@@ -5,11 +5,25 @@ import { ref } from 'vue'
* Verified by checking response headers from each app container.
* These always open in a new tab. Other apps load in the iframe overlay.
*/
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
* Sites listed here that also appear in EXTERNAL_PROXY will be proxied (not blocked).
*/
const IFRAME_BLOCKED_HOSTS: string[] = []
/** External sites proxied through nginx to strip X-Frame-Options for iframe embedding */
const EXTERNAL_PROXY: Record<string, string> = {
'botfights.net': '/ext/botfights/',
'484.kitchen': '/ext/484-kitchen/',
'present.l484.com': '/ext/arch-presentation/',
}
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
// External sites — third-party cookie/iframe restrictions
if (u.hostname.includes('indeehub')) return true
// External sites that block iframes
if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) {
return true
}
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
if (
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
@@ -67,6 +81,13 @@ function toEmbeddableUrl(url: string): string {
try {
const u = new URL(url)
const origin = window.location.origin
// External sites proxied through nginx to strip X-Frame-Options
const extProxy = EXTERNAL_PROXY[u.hostname]
if (extProxy) {
return `${origin}${extProxy}`
}
const proxyPath = PORT_TO_PROXY[u.port]
const sameHost = u.hostname === window.location.hostname
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
+10
View File
@@ -95,6 +95,14 @@ export const useCloudStore = defineStore('cloud', () => {
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 = []
@@ -116,6 +124,8 @@ export const useCloudStore = defineStore('cloud', () => {
uploadFile,
deleteItem,
downloadUrl,
fetchBlobUrl,
downloadFile,
reset,
}
})
+2 -2
View File
@@ -165,7 +165,7 @@ export const useContainerStore = defineStore('container', () => {
containers.value = await containerClient.listContainers()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch containers'
console.error('Failed to fetch containers:', e)
if (import.meta.env.DEV) console.error('Failed to fetch containers:', e)
} finally {
loading.value = false
}
@@ -175,7 +175,7 @@ export const useContainerStore = defineStore('container', () => {
try {
healthStatus.value = await containerClient.getHealthStatus()
} catch (e) {
console.error('Failed to fetch health status:', e)
if (import.meta.env.DEV) console.error('Failed to fetch health status:', e)
}
}
+10
View File
@@ -4,9 +4,19 @@ export interface DataModel {
'server-info': ServerInfo
'package-data': { [id: string]: PackageDataEntry }
'peer-health'?: { [onion: string]: boolean }
notifications?: AppNotification[]
ui: UIData
}
export interface AppNotification {
id: string
level: 'info' | 'warning' | 'error'
title: string
message: string
timestamp: string
app_id?: string
}
export interface ServerInfo {
id: string
version: string
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchGitHubAppInfo, fetchMultipleAppInfo } from '../githubAppInfo'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('fetchGitHubAppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty object for invalid repo URL', async () => {
const result = await fetchGitHubAppInfo('not-a-url', 'test')
expect(result).toEqual({})
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns empty object when repo API returns non-OK', async () => {
// Start9 repo check
mockFetch.mockResolvedValueOnce({ ok: false })
// Original repo fetch fails
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 })
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
expect(result).toEqual({})
})
it('fetches repo info successfully', async () => {
// Start9 wrapper check — not found
mockFetch.mockResolvedValueOnce({ ok: false })
// Repo API call
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
description: 'A Bitcoin node',
homepage: 'https://bitcoin.org',
html_url: 'https://github.com/owner/repo',
}),
})
// README fetch
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ content: btoa('# README') }),
})
// Icon path checks — all fail
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
// Releases check — no icon
mockFetch.mockResolvedValueOnce({ ok: false })
// Raw icon URL HEAD checks — all fail
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
expect(result.description).toBe('A Bitcoin node')
expect(result.readme).toBe('# README')
expect(result.homepage).toBe('https://bitcoin.org')
})
it('finds icon from repository contents', async () => {
// Start9 wrapper — not found
mockFetch.mockResolvedValueOnce({ ok: false })
// Repo API
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ description: '', html_url: 'https://github.com/o/r' }),
})
// README
mockFetch.mockResolvedValueOnce({ ok: false })
// First icon path (icon.png) — found!
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ download_url: 'https://raw.github.com/o/r/main/icon.png' }),
})
const result = await fetchGitHubAppInfo('https://github.com/o/r', 'test')
expect(result.icon).toBe('https://raw.github.com/o/r/main/icon.png')
})
it('tries Start9Labs wrapper repo first', async () => {
// Start9 wrapper check — found!
mockFetch.mockResolvedValueOnce({ ok: true })
// Now fetches Start9Labs/app-startos repo
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
description: 'Start9 wrapper',
html_url: 'https://github.com/Start9Labs/bitcoin-startos',
}),
})
// README
mockFetch.mockResolvedValueOnce({ ok: false })
// Icon paths — all fail
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
// Releases
mockFetch.mockResolvedValueOnce({ ok: false })
// Raw URLs
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({ ok: false })
}
const result = await fetchGitHubAppInfo('https://github.com/bitcoin/bitcoin', 'bitcoin')
expect(result.description).toBe('Start9 wrapper')
})
it('handles fetch errors gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network error'))
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
expect(result).toEqual({})
})
})
describe('fetchMultipleAppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
it('returns empty record for empty input', async () => {
const result = await fetchMultipleAppInfo([])
expect(result).toEqual({})
})
it('fetches info for multiple apps', async () => {
// Each app triggers multiple fetch calls, but they all return non-matching URLs
mockFetch.mockResolvedValue({ ok: false })
const apps = [
{ id: 'app1', 'wrapper-repo': 'not-a-github-url' },
{ id: 'app2', 'wrapper-repo': 'also-invalid' },
]
const result = await fetchMultipleAppInfo(apps)
expect(result.app1).toEqual({})
expect(result.app2).toEqual({})
})
})
+4 -4
View File
@@ -18,7 +18,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
// Extract owner and repo from URL
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/)
if (!match) {
console.warn(`[GitHub] Invalid repo URL: ${repoUrl}`)
if (import.meta.env.DEV) console.warn(`[GitHub] Invalid repo URL: ${repoUrl}`)
return {}
}
@@ -49,7 +49,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
const repoResponse = await fetch(repoApiUrl)
if (!repoResponse.ok) {
console.warn(`[GitHub] Failed to fetch repo ${targetOwner}/${targetRepo}: ${repoResponse.status}`)
if (import.meta.env.DEV) console.warn(`[GitHub] Failed to fetch repo ${targetOwner}/${targetRepo}: ${repoResponse.status}`)
return {}
}
@@ -64,7 +64,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
readme = atob(readmeData.content) // Base64 decode
}
} catch (e) {
console.warn(`[GitHub] Failed to fetch README for ${targetOwner}/${targetRepo}`)
if (import.meta.env.DEV) console.warn(`[GitHub] Failed to fetch README for ${targetOwner}/${targetRepo}`)
}
// Try to find icon in repository
@@ -144,7 +144,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
homepage: repoData.homepage || repoData.html_url
}
} catch (error) {
console.error(`[GitHub] Error fetching app info for ${repoUrl}:`, error)
if (import.meta.env.DEV) console.error(`[GitHub] Error fetching app info for ${repoUrl}:`, error)
return {}
}
}
+8 -1
View File
@@ -784,7 +784,14 @@ function launchApp() {
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' }
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'https://botfights.net', prod: 'https://botfights.net' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
'484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' },
'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' },
'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' },
'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' },
't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' }
}
if (appUrls[id]) {
+84 -18
View File
@@ -69,8 +69,9 @@
@click="goToApp(id as string)"
@keydown.enter="goToApp(id as string)"
>
<!-- Uninstall Icon -->
<!-- Uninstall Icon (not for web-only apps) -->
<button
v-if="!isWebOnlyApp(id as string)"
@click.stop="showUninstallModal(id as string, pkg)"
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
@@ -120,7 +121,7 @@
{{ t('common.launch') }}
</button>
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="startApp(id as string)"
:disabled="loadingActions[id as string]"
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -139,7 +140,7 @@
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
</button>
<button
v-if="pkg.state === 'running' || pkg.state === 'starting'"
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="stopApp(id as string)"
:disabled="loadingActions[id as string]"
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -251,19 +252,81 @@ function showActionError(msg: string) {
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
// Use real packages from store - no more dummy apps
// Web-only app IDs and their URLs
const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
function isWebOnlyApp(id: string): boolean {
return id in WEB_ONLY_APP_URLS
}
// Web-only apps (no container) — always show as installed bookmarks
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
'indeedhub': {
state: 'running' as PackageState,
manifest: { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: { short: 'Bitcoin documentary streaming platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/indeedhub.png' },
},
'botfights': {
state: 'running' as PackageState,
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/botfights.svg' },
},
'nwnn': {
state: 'running' as PackageState,
manifest: { id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', description: { short: 'Decentralized news aggregator, synced from Telegram', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/nwnn.png' },
},
'484-kitchen': {
state: 'running' as PackageState,
manifest: { id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', description: { short: 'K484 application platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/484-kitchen.png' },
},
'call-the-operator': {
state: 'running' as PackageState,
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
},
'arch-presentation': {
state: 'running' as PackageState,
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
},
'syntropy-institute': {
state: 'running' as PackageState,
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/syntropy-institute.png' },
},
't-zero': {
state: 'running' as PackageState,
manifest: { id: 't-zero', title: 'T-0', version: '1.0.0', description: { short: 'Documentary series on decentralization and Bitcoin', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/t-zero.png' },
},
}
// Merge real packages from store with web-only app bookmarks
const packages = computed(() => {
const realPackages = store.packages
if (import.meta.env.DEV) console.log('[Apps] Real packages from store:', Object.keys(realPackages || {}).length, 'apps')
return realPackages || {}
const realPackages = store.packages || {}
return { ...WEB_ONLY_APPS, ...realPackages }
})
// Sorted by manifest title, case-insensitive; order stable regardless of running/stopped
// Web-only apps first (alphabetically), then all other apps (alphabetically)
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
return entries.sort(([, a], [, b]) =>
(a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
)
return entries.sort(([idA, a], [idB, b]) => {
const aWeb = isWebOnlyApp(idA) ? 0 : 1
const bWeb = isWebOnlyApp(idB) ? 0 : 1
if (aWeb !== bWeb) return aWeb - bWeb
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
})
})
const filteredPackageEntries = computed(() => {
@@ -295,10 +358,10 @@ useModalKeyboard(
)
function canLaunch(pkg: PackageDataEntry): boolean {
// For dummy apps, allow launch if running (they have interface addresses)
// Web-only apps are always launchable
if (isWebOnlyApp(pkg.manifest.id)) return true
// For real apps, check for UI interface
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
// Allow launch when running or starting (so buttons show even while backend reports "starting")
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting'
return !!hasUI && canLaunchState
}
@@ -306,7 +369,14 @@ function canLaunch(pkg: PackageDataEntry): boolean {
function launchApp(id: string) {
const isDev = import.meta.env.DEV
const pkg = packages.value[id]
// Web-only apps — use their external URL directly
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg?.manifest?.title || id })
return
}
// Explicit URLs for apps that need them (checked first to avoid package data issues)
const appUrls: Record<string, { dev: string, prod: string }> = {
'lorabell': {
@@ -321,10 +391,6 @@ function launchApp(id: string) {
dev: 'http://localhost:8103',
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
}
}
if (appUrls[id]) {
+28 -5
View File
@@ -22,12 +22,12 @@
</svg>
</button>
<!-- Loading indicator while iframe loads -->
<!-- Loading indicator while checking availability or iframe loads -->
<Transition name="fade">
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
<div v-if="aiuiAvailable === null || (aiuiUrl && !aiuiConnected)" class="chat-loading" role="status" aria-live="polite">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" aria-hidden="true" />
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
<p class="text-sm text-white/60">{{ aiuiAvailable === null ? t('chat.loadingAssistant') : t('chat.loadingAssistant') }}</p>
</div>
</div>
</Transition>
@@ -78,13 +78,35 @@ const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true`
if (import.meta.env.PROD) return '/aiui/?embedded=true'
// In production, only return the URL if we've confirmed AIUI files exist
if (import.meta.env.PROD && aiuiAvailable.value === true) return '/aiui/?embedded=true'
return ''
})
/** Check if AIUI is actually deployed by fetching its index.html */
async function checkAiuiAvailable() {
if (import.meta.env.VITE_AIUI_URL) {
aiuiAvailable.value = true
return
}
if (!import.meta.env.PROD) {
aiuiAvailable.value = false
return
}
try {
const res = await fetch('/aiui/', { method: 'HEAD' })
// If we get HTML back (200), AIUI is deployed. If 404/403, it's not.
aiuiAvailable.value = res.ok
} catch {
aiuiAvailable.value = false
}
}
function closeChat() {
if (window.history.length > 1) {
router.back()
@@ -106,8 +128,9 @@ function onAiuiMessage(event: MessageEvent) {
}
}
onMounted(() => {
onMounted(async () => {
window.addEventListener('message', onAiuiMessage)
await checkAiuiAvailable()
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
+2 -2
View File
@@ -110,7 +110,7 @@
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span>Chat</span>
<span>AIUI</span>
</button>
<!-- Logout - styled as nav item, below Settings -->
@@ -336,7 +336,7 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span class="text-[10px] leading-tight">Chat</span>
<span class="text-[10px] leading-tight">AIUI</span>
</button>
</div>
</nav>
+284
View File
@@ -0,0 +1,284 @@
<template>
<div class="kiosk-root" tabindex="0" ref="kioskRoot">
<!-- Kiosk launcher grid -->
<div class="kiosk-launcher">
<!-- Header -->
<div class="kiosk-header">
<div class="flex items-center gap-4">
<img :src="FALLBACK_ICON" alt="Archipelago" class="w-10 h-10" />
<div>
<h1 class="text-2xl font-bold text-white font-archipelago">Archipelago</h1>
<p class="text-sm text-white/50">{{ currentTime }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="kiosk-status-pill" :class="isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'">
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
</div>
</div>
</div>
<!-- App grid -->
<div class="kiosk-grid">
<button
v-for="app in launchableApps"
:key="app.id"
class="kiosk-app-tile"
@click="openApp(app)"
:data-controller-focusable="true"
>
<div class="kiosk-app-icon-wrap">
<img
:src="app.icon"
:alt="app.title"
class="kiosk-app-icon"
@error="($event.target as HTMLImageElement).src = FALLBACK_ICON"
/>
<div
class="kiosk-app-status"
:class="app.running ? 'bg-green-400' : 'bg-white/30'"
/>
</div>
<span class="kiosk-app-label">{{ app.title }}</span>
</button>
</div>
<!-- Footer -->
<div class="kiosk-footer">
<span class="text-white/30 text-sm">{{ t('kiosk.navHint') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
const { t } = useI18n()
const store = useAppStore()
const appLauncher = useAppLauncherStore()
const kioskRoot = ref<HTMLElement | null>(null)
interface KioskApp {
id: string
title: string
icon: string
url: string
running: boolean
}
// Public asset path construct with BASE_URL to avoid Vite resolving it as a module import
const FALLBACK_ICON = `${import.meta.env.BASE_URL}assets/img/favico.png`
const currentTime = ref('')
const isConnected = computed(() => store.isConnected)
// Build list of launchable apps from the store's package data
const launchableApps = computed<KioskApp[]>(() => {
const pkgs = store.data?.['package-data'] || {}
const apps: KioskApp[] = []
// App URL mappings use nginx proxy paths for local apps
const urlMap: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'lnd': '/app/lnd/',
'mempool': '/app/mempool/',
'btcpay-server': '/app/btcpay/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'jellyfin': '/app/jellyfin/',
'nextcloud': '/app/nextcloud/',
'immich': '/app/immich/',
'photoprism': '/app/photoprism/',
'vaultwarden': '/app/vaultwarden/',
'filebrowser': '/app/filebrowser/',
'searxng': '/app/searxng/',
'ollama': '/app/ollama/',
'penpot': '/app/penpot/',
'onlyoffice': '/app/onlyoffice/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
for (const [id, pkg] of Object.entries(pkgs)) {
const url = urlMap[id]
if (!url) continue
const isRunning = pkg.state === 'running' ||
pkg.installed?.status === 'running'
apps.push({
id,
title: pkg.manifest?.title || id,
icon: pkg['static-files']?.icon || FALLBACK_ICON,
url,
running: isRunning,
})
}
// Sort: running apps first, then alphabetical
return apps.sort((a, b) => {
if (a.running !== b.running) return a.running ? -1 : 1
return a.title.localeCompare(b.title)
})
})
function openApp(app: KioskApp) {
// Delegate to the app launcher handles iframe overlay vs new-tab
appLauncher.open({ url: app.url, title: app.title })
}
// Clock updater
let clockInterval: ReturnType<typeof setInterval> | undefined
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
onMounted(() => {
updateClock()
clockInterval = setInterval(updateClock, 30000)
kioskRoot.value?.focus()
// Connect WebSocket if not already
if (!store.isConnected) {
store.connectWebSocket().catch(() => {})
}
})
onUnmounted(() => {
if (clockInterval) clearInterval(clockInterval)
})
</script>
<style scoped>
.kiosk-root {
position: fixed;
inset: 0;
background: #000;
outline: none;
overflow: hidden;
z-index: 9999;
}
.kiosk-launcher {
height: 100vh;
display: flex;
flex-direction: column;
padding: 2rem 3rem;
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
}
.kiosk-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 2rem;
}
.kiosk-status-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.kiosk-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1.5rem;
align-content: start;
overflow-y: auto;
padding: 0.5rem;
}
.kiosk-app-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 0.75rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.25s ease;
cursor: pointer;
}
.kiosk-app-tile:hover,
.kiosk-app-tile:focus-visible {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(251, 146, 60, 0.4);
transform: scale(1.05);
box-shadow: 0 0 30px rgba(251, 146, 60, 0.15);
outline: none;
}
.kiosk-app-icon-wrap {
position: relative;
width: 64px;
height: 64px;
}
.kiosk-app-icon {
width: 64px;
height: 64px;
border-radius: 16px;
object-fit: cover;
background: rgba(255, 255, 255, 0.05);
}
.kiosk-app-status {
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 3px solid #000;
}
.kiosk-app-label {
font-size: 0.8125rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kiosk-footer {
padding-top: 1.5rem;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 1.5rem;
}
</style>
+95 -1
View File
@@ -397,6 +397,7 @@ const categories = computed(() => [
{ id: 'home', name: t('marketplace.homeCategory') },
{ id: 'car', name: t('marketplace.auto') },
{ id: 'networking', name: t('marketplace.networking') },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: t('marketplace.other') }
])
@@ -522,11 +523,14 @@ const installedPackages = computed(() => {
// Function to categorize community apps based on their ID and description
function categorizeCommunityApp(app: MarketplaceApp): string {
// If app already has a category set, use it
if (app.category) return app.category
const id = app.id.toLowerCase()
const title = app.title?.toLowerCase() || ''
const description = (typeof app.description === 'string' ? app.description : app.description?.short ?? '').toLowerCase()
const combined = `${id} ${title} ${description}`
// Money category
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') ||
id.includes('lnd') || id.includes('cln') || id.includes('electr') ||
@@ -949,6 +953,96 @@ function getCuratedAppList() {
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:0.9.0',
manifestUrl: undefined,
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
},
{
id: 'botfights',
title: 'BotFights',
version: '1.0.0',
description: 'AI bot arena — build, train, and battle autonomous agents. Compete in strategy tournaments with your own coded bots.',
icon: '/assets/img/app-icons/botfights.svg',
author: 'BotFights',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://botfights.net',
webUrl: 'https://botfights.net'
},
{
id: 'nwnn',
title: 'Next Web News Network',
version: '1.0.0',
category: 'l484',
description: 'Decentralized news and link aggregator, synchronized from Telegram. Community-curated content on Bitcoin, sovereignty, and decentralized tech.',
icon: '/assets/img/app-icons/nwnn.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://nwnn.l484.com',
webUrl: 'https://nwnn.l484.com'
},
{
id: '484-kitchen',
title: '484 Kitchen',
version: '1.0.0',
category: 'l484',
description: 'K484 application platform — an internal tool for the L484 network.',
icon: '/assets/img/app-icons/484-kitchen.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://484.kitchen',
webUrl: 'https://484.kitchen'
},
{
id: 'call-the-operator',
title: 'Call the Operator',
version: '1.0.0',
category: 'l484',
description: 'Escape the Matrix — a portal for exploring decentralized alternatives and reclaiming digital sovereignty.',
icon: '/assets/img/app-icons/call-the-operator.png',
author: 'TX1138',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://cta.tx1138.com',
webUrl: 'https://cta.tx1138.com'
},
{
id: 'arch-presentation',
title: 'Arch Presentation',
version: '1.0.0',
category: 'l484',
description: 'Archipelago: The Future of Decentralized Infrastructure — an interactive presentation about the Archipelago project vision.',
icon: '/assets/img/app-icons/arch-presentation.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://present.l484.com',
webUrl: 'https://present.l484.com'
},
{
id: 'syntropy-institute',
title: 'Syntropy Institute',
version: '1.0.0',
category: 'l484',
description: 'Medicine Reimagined — Manual Kinetics, Syntropy Frequency analysis-therapy, digital homeopathy, and concierge protocols.',
icon: '/assets/img/app-icons/syntropy-institute.png',
author: 'Syntropy Institute',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://syntropy.institute',
webUrl: 'https://syntropy.institute'
},
{
id: 't-zero',
title: 'T-0',
version: '1.0.0',
category: 'l484',
description: 'Documentary series exploring decentralization, Bitcoin, and the mavericks building the ungovernable future. Conversations with the builders, powered by Nostr.',
icon: '/assets/img/app-icons/t-zero.png',
author: 'T-0',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://teeminuszero.net',
webUrl: 'https://teeminuszero.net'
}
]
}
@@ -374,6 +374,7 @@ import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useAppLauncherStore } from '../stores/appLauncher'
const { t } = useI18n()
const { bottomPosition } = useMobileBackButton()
@@ -391,8 +392,14 @@ const loading = ref(true)
const appId = computed(() => route.params.id as string)
// Web-only apps (no container, just a URL) always treated as "installed"
const isWebOnly = computed(() => {
return !!(app.value?.webUrl && !app.value?.dockerImage)
})
// Check if app is already installed
const isInstalled = computed(() => {
if (isWebOnly.value) return true
return !!store.packages[appId.value]
})
@@ -503,6 +510,14 @@ function goBack() {
}
function goToInstalledApp() {
// Web-only apps: launch directly via appLauncher
if (isWebOnly.value && app.value?.webUrl) {
useAppLauncherStore().open({
url: app.value.webUrl,
title: app.value.title || appId.value,
})
return
}
router.push({
path: `/dashboard/apps/${appId.value}`,
query: { from: 'marketplace' }
@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount, VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
// Mock rpc-client before importing anything that uses it
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({ backups: [] }),
login: vi.fn(),
logout: vi.fn(),
changePassword: vi.fn(),
totpStatus: vi.fn().mockResolvedValue({ enabled: false }),
totpSetupBegin: vi.fn(),
totpSetupConfirm: vi.fn(),
totpDisable: vi.fn(),
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
},
}))
// Mock 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(),
}))
// Stub the ControllerIndicator component
vi.mock('@/components/ControllerIndicator.vue', () => ({
default: defineComponent({ name: 'ControllerIndicator', render: () => h('div') }),
}))
// Mock useModalKeyboard composable
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
// Stub vue-router
const pushMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushMock,
}),
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
}))
// Stub global fetch for the Claude status check in onMounted
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not available')))
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import Settings from '../Settings.vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
const mockedRpc = vi.mocked(rpcClient)
function mountSettings(storeOverrides?: Partial<ReturnType<typeof useAppStore>>): VueWrapper {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
// Set default store state for tests
store.isAuthenticated = true
store.$patch({
data: {
'server-info': {
id: 'test-node',
version: '0.1.0',
name: 'Test Node',
pubkey: 'test-pubkey',
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
'lan-address': '192.168.1.100',
'tor-address': null,
unread: 0,
'wifi-ssids': [],
'zram-enabled': false,
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
if (storeOverrides) {
store.$patch(storeOverrides as Record<string, unknown>)
}
return shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
}
describe('Settings View', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockedRpc.totpStatus.mockResolvedValue({ enabled: false })
mockedRpc.call.mockResolvedValue({ backups: [] })
mockedRpc.getTorAddress.mockResolvedValue({ tor_address: null })
pushMock.mockResolvedValue(undefined)
})
it('renders without errors', () => {
const wrapper = mountSettings()
expect(wrapper.exists()).toBe(true)
})
it('displays the Settings heading', () => {
const wrapper = mountSettings()
const heading = wrapper.find('h1')
expect(heading.exists()).toBe(true)
expect(heading.text()).toBe('Settings')
})
it('displays the Account section with server name and version', () => {
const wrapper = mountSettings()
const html = wrapper.html()
// Account section heading
const sectionHeadings = wrapper.findAll('h2')
const accountHeading = sectionHeadings.find((h) => h.text() === 'Account')
expect(accountHeading).toBeDefined()
// Server name rendered
expect(html).toContain('Test Node')
// Version rendered
expect(html).toContain('0.1.0')
})
it('displays the version from server info', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('0.1.0')
expect(html).toContain('Version')
})
it('displays the Interface Mode section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const modeHeading = sectionHeadings.find((h) => h.text() === 'Interface Mode')
expect(modeHeading).toBeDefined()
})
it('displays the Claude Authentication section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const claudeHeading = sectionHeadings.find((h) => h.text() === 'Claude Authentication')
expect(claudeHeading).toBeDefined()
})
it('displays the AI Data Access section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const aiHeading = sectionHeadings.find((h) => h.text() === 'AI Data Access')
expect(aiHeading).toBeDefined()
})
it('displays the System Updates section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const updatesHeading = sectionHeadings.find((h) => h.text() === 'System Updates')
expect(updatesHeading).toBeDefined()
})
it('displays the Backup & Restore section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const backupHeading = sectionHeadings.find((h) => h.text().includes('Backup'))
expect(backupHeading).toBeDefined()
})
it('displays the Network section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const networkHeading = sectionHeadings.find((h) => h.text() === 'Network')
expect(networkHeading).toBeDefined()
})
it('displays a Logout button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
expect(logoutButton!.exists()).toBe(true)
})
it('logout button triggers store logout and navigates to login', async () => {
const wrapper = mountSettings()
const store = useAppStore()
const logoutSpy = vi.spyOn(store, 'logout').mockResolvedValue()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
await logoutButton!.trigger('click')
// Allow async handlers to settle
await vi.dynamicImportSettled()
expect(logoutSpy).toHaveBeenCalled()
expect(pushMock).toHaveBeenCalledWith('/login')
})
it('displays a Change Password button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const changePasswordButton = buttons.find((b) => b.text().includes('Change Password'))
expect(changePasswordButton).toBeDefined()
expect(changePasswordButton!.exists()).toBe(true)
})
it('displays Two-Factor Authentication section with status', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('Two-Factor Authentication')
})
it('shows Enable 2FA button when TOTP is not enabled', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const enable2faButton = buttons.find((b) => b.text().includes('Enable 2FA'))
expect(enable2faButton).toBeDefined()
})
it('displays session status as currently logged in', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Currently logged in')
})
it('shows server name from the store', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Server Name')
expect(wrapper.html()).toContain('Test Node')
})
it('defaults version to 0.0.0 when server info has no version', () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
store.$patch({
isAuthenticated: true,
data: {
'server-info': {
id: 'test',
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': [],
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
const wrapper = shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
// When version is empty string, computed returns '0.0.0' from the fallback
const html = wrapper.html()
expect(html).toContain('0.0.0')
})
it('calls totpStatus on mount to check 2FA state', async () => {
mountSettings()
// onMounted calls loadTotpStatus which calls rpcClient.totpStatus
expect(mockedRpc.totpStatus).toHaveBeenCalled()
})
it('calls backup.list on mount to load backups', async () => {
mountSettings()
// onMounted calls loadBackups which calls rpcClient.call with backup.list
expect(mockedRpc.call).toHaveBeenCalledWith({ method: 'backup.list' })
})
})