Major changes: - Full Tor hidden service management via systemd path unit pattern (tor-helper.sh + archipelago-tor-helper.path/service) — respects NoNewPrivileges=yes, no sudo needed from backend - Container doctor: prefer system Tor over container, remove archy-tor - Deploy script: fix torrc generation (read correct services.json path), web apps map port 80→local port, enable both tor and tor@default - Federation: server rename pushes name to peers via background sync - Server name: fix root-owned file, optimistic store update - Mesh: local echo for sent messages, sendingArch loading state - Web5: Message button → Mesh redirect, node name lookup in messages - PeerFiles: show DID not onion in header - Connected Nodes: flex-1 instead of fixed max-h - Toast notifications route to Mesh - Deploy script: fix single-quote syntax in SSH block Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
325 lines
9.9 KiB
TypeScript
325 lines
9.9 KiB
TypeScript
// Main application store using Pinia
|
|
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import type { DataModel } from '../types/api'
|
|
import { wsClient, applyDataPatch } from '../api/websocket'
|
|
import { rpcClient } from '../api/rpc-client'
|
|
|
|
export const useAppStore = defineStore('app', () => {
|
|
// State
|
|
const data = ref<DataModel | null>(null)
|
|
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
|
|
const isConnected = ref(false)
|
|
const isReconnecting = ref(false)
|
|
const isLoading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
let isWsSubscribed = false
|
|
let sessionValidated = false
|
|
|
|
// Computed
|
|
const serverInfo = computed(() => data.value?.['server-info'])
|
|
const packages = computed(() => data.value?.['package-data'] || {})
|
|
const peerHealth = computed<Record<string, boolean>>(() => data.value?.['peer-health'] || {})
|
|
const uiData = computed(() => data.value?.ui)
|
|
const serverName = computed(() => serverInfo.value?.name || 'Archipelago')
|
|
const isRestarting = computed(() => serverInfo.value?.['status-info']?.restarting || false)
|
|
const isShuttingDown = computed(() => serverInfo.value?.['status-info']?.['shutting-down'] || false)
|
|
const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value)
|
|
|
|
// Actions
|
|
async function login(password: string): Promise<{ requires_totp?: boolean }> {
|
|
isLoading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
const result = await rpcClient.login(password)
|
|
if (result && result.requires_totp) {
|
|
return { requires_totp: true }
|
|
}
|
|
|
|
isAuthenticated.value = true
|
|
sessionValidated = true
|
|
localStorage.setItem('neode-auth', 'true')
|
|
|
|
// Initialize data structure immediately so dashboard can render
|
|
await initializeData()
|
|
|
|
// Connect WebSocket in background - don't block login flow
|
|
connectWebSocket().catch((err) => {
|
|
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err)
|
|
})
|
|
return {}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Login failed'
|
|
throw err
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function completeLoginAfterTotp(): Promise<void> {
|
|
isAuthenticated.value = true
|
|
sessionValidated = true
|
|
localStorage.setItem('neode-auth', 'true')
|
|
await initializeData()
|
|
connectWebSocket().catch((err) => {
|
|
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
|
})
|
|
}
|
|
|
|
async function logout(): Promise<void> {
|
|
try {
|
|
await rpcClient.logout()
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Logout error:', err)
|
|
} finally {
|
|
isAuthenticated.value = false
|
|
sessionValidated = false
|
|
localStorage.removeItem('neode-auth')
|
|
data.value = null
|
|
isWsSubscribed = false
|
|
wsClient.disconnect()
|
|
isConnected.value = false
|
|
isReconnecting.value = false
|
|
}
|
|
}
|
|
|
|
async function connectWebSocket(): Promise<void> {
|
|
try {
|
|
if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...')
|
|
isReconnecting.value = true
|
|
|
|
// Don't create multiple subscriptions - check if already subscribed
|
|
if (!isWsSubscribed) {
|
|
// Subscribe to updates BEFORE connecting (so we catch initial data)
|
|
isWsSubscribed = true
|
|
|
|
// Listen for connection state changes
|
|
wsClient.onConnectionStateChange((state) => {
|
|
if (import.meta.env.DEV) console.log('[Store] WebSocket connection state changed:', state)
|
|
isConnected.value = state === 'connected'
|
|
isReconnecting.value = state === 'connecting'
|
|
})
|
|
|
|
wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => {
|
|
// Handle mock backend format: {type: 'initial', data: {...}}
|
|
if (update?.type === 'initial' && update?.data) {
|
|
if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend')
|
|
data.value = update.data
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
// Handle real backend format: {rev: 0, data: {...}}
|
|
else if (update?.data && update?.rev !== undefined) {
|
|
data.value = update.data
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
// Handle patch updates (both backends)
|
|
else if (data.value && update?.patch) {
|
|
try {
|
|
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
|
|
data.value = applyDataPatch(data.value, update.patch)
|
|
// Mark as connected once we receive any valid patch
|
|
if (!isConnected.value) {
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Now connect (or reconnect if already connected)
|
|
// Only attempt to connect if not already connected
|
|
if (wsClient.isConnected()) {
|
|
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
return
|
|
}
|
|
|
|
await wsClient.connect()
|
|
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
|
|
|
|
// Connection state will be updated via the callback
|
|
if (wsClient.isConnected()) {
|
|
isConnected.value = true
|
|
isReconnecting.value = false
|
|
}
|
|
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('[Store] WebSocket connection failed:', err)
|
|
// Don't mark as disconnected immediately - let reconnection logic handle it
|
|
// The WebSocket client will retry automatically
|
|
isReconnecting.value = true
|
|
isConnected.value = false
|
|
// Don't throw - allow app to work without real-time updates
|
|
// The WebSocket will reconnect in the background
|
|
}
|
|
}
|
|
|
|
async function initializeData(): Promise<void> {
|
|
// Initialize with empty data structure
|
|
// The WebSocket will populate it with real data
|
|
data.value = {
|
|
'server-info': {
|
|
id: '',
|
|
version: '',
|
|
name: null,
|
|
pubkey: '',
|
|
'status-info': {
|
|
restarting: false,
|
|
'shutting-down': false,
|
|
updated: false,
|
|
'backup-progress': null,
|
|
'update-progress': null,
|
|
},
|
|
'lan-address': null,
|
|
'tor-address': null,
|
|
unread: 0,
|
|
'wifi-ssids': [],
|
|
'zram-enabled': false,
|
|
},
|
|
'package-data': {},
|
|
ui: {
|
|
name: null,
|
|
'ack-welcome': '',
|
|
marketplace: {
|
|
'selected-hosts': [],
|
|
'known-hosts': {},
|
|
},
|
|
theme: 'dark',
|
|
},
|
|
}
|
|
}
|
|
|
|
// Check session validity on app load or stale auth
|
|
async function checkSession(): Promise<boolean> {
|
|
if (!localStorage.getItem('neode-auth')) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
|
|
isAuthenticated.value = true
|
|
sessionValidated = true
|
|
|
|
await initializeData()
|
|
|
|
connectWebSocket().catch((err) => {
|
|
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
|
|
isReconnecting.value = true
|
|
})
|
|
|
|
return true
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('[Store] Session check failed:', err)
|
|
localStorage.removeItem('neode-auth')
|
|
isAuthenticated.value = false
|
|
sessionValidated = false
|
|
isWsSubscribed = false
|
|
isConnected.value = false
|
|
isReconnecting.value = false
|
|
wsClient.disconnect()
|
|
return false
|
|
}
|
|
}
|
|
|
|
function needsSessionValidation(): boolean {
|
|
return isAuthenticated.value && !sessionValidated
|
|
}
|
|
|
|
// Package actions
|
|
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
|
|
return rpcClient.installPackage(id, marketplaceUrl, version)
|
|
}
|
|
|
|
async function uninstallPackage(id: string): Promise<void> {
|
|
return rpcClient.uninstallPackage(id)
|
|
}
|
|
|
|
async function startPackage(id: string): Promise<void> {
|
|
return rpcClient.startPackage(id)
|
|
}
|
|
|
|
async function stopPackage(id: string): Promise<void> {
|
|
return rpcClient.stopPackage(id)
|
|
}
|
|
|
|
async function restartPackage(id: string): Promise<void> {
|
|
return rpcClient.restartPackage(id)
|
|
}
|
|
|
|
// Server actions
|
|
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
|
|
return rpcClient.updateServer(marketplaceUrl)
|
|
}
|
|
|
|
async function restartServer(): Promise<void> {
|
|
return rpcClient.restartServer()
|
|
}
|
|
|
|
async function shutdownServer(): Promise<void> {
|
|
return rpcClient.shutdownServer()
|
|
}
|
|
|
|
async function getMetrics(): Promise<Record<string, unknown>> {
|
|
return rpcClient.getMetrics()
|
|
}
|
|
|
|
// Marketplace actions
|
|
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
|
|
return rpcClient.getMarketplace(url)
|
|
}
|
|
|
|
function updateServerName(name: string) {
|
|
if (data.value?.['server-info']) {
|
|
data.value['server-info'].name = name
|
|
}
|
|
}
|
|
|
|
return {
|
|
// State
|
|
data,
|
|
isAuthenticated,
|
|
isConnected,
|
|
isReconnecting,
|
|
isLoading,
|
|
error,
|
|
|
|
// Computed
|
|
serverInfo,
|
|
packages,
|
|
peerHealth,
|
|
uiData,
|
|
serverName,
|
|
isRestarting,
|
|
isShuttingDown,
|
|
isOffline,
|
|
|
|
// Actions
|
|
login,
|
|
completeLoginAfterTotp,
|
|
logout,
|
|
checkSession,
|
|
needsSessionValidation,
|
|
connectWebSocket,
|
|
installPackage,
|
|
uninstallPackage,
|
|
startPackage,
|
|
stopPackage,
|
|
restartPackage,
|
|
updateServer,
|
|
restartServer,
|
|
shutdownServer,
|
|
getMetrics,
|
|
getMarketplace,
|
|
updateServerName,
|
|
}
|
|
})
|
|
|