diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f2a0c306 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,276 @@ +# CLAUDE.md — Archipelago (Archy) Project Guide + +## Project Overview + +Archipelago is a **Bitcoin Node OS** — a bootable, self-sovereign personal server you flash to USB, install on hardware, and manage via a web UI. Similar to Umbrel/Start9/RaspiBlitz but custom-built with production-grade security. + +**Stack**: Rust backend + Vue 3 (Composition API) + TypeScript (strict) + Vite 7 + Tailwind CSS + Pinia + Podman +**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64 +**Current version**: 0.1.0 + +## Quick Reference + +```bash +# Frontend local dev (mock backend on :5959, Vite on :8100) +cd neode-ui && npm start + +# Deploy to live server (frontend + backend + restart services) +./scripts/deploy-to-target.sh --live + +# Deploy to both servers +./scripts/deploy-to-target.sh --both + +# Frontend build (outputs to web/dist/neode-ui/) +cd neode-ui && npm run build + +# Type-check frontend +cd neode-ui && npm run type-check + +# Rust checks (run on dev server, NOT macOS) +cargo clippy --all-targets --all-features +cargo fmt --all +cargo test --all-features +``` + +Dev server: `http://192.168.1.228` | Local frontend: `http://localhost:8100` (password: `password123`) + +## Architecture + +``` +Debian 12 (Bookworm) + ├── Podman (rootless containers) + ├── Nginx (port 80 → proxies /rpc/, /ws/, /health to backend) + ├── Rust Backend (core/) — binary on port 5678 + │ ├── core/archipelago/ — Main binary, RPC endpoints + │ ├── core/container/ — PodmanClient, manifest parser, dependency resolver, health monitor + │ ├── core/security/ — AppArmor profiles, secrets manager, Cosign image verifier + │ ├── core/performance/ — Resource manager + │ └── core/parmanode/ — Parmanode compatibility layer + └── Vue.js UI (neode-ui/) + ├── src/api/ — RPC client (rpc-client.ts), WebSocket, container client + ├── src/stores/ — Pinia stores + ├── src/views/ — Page components + ├── src/components/ — Reusable components + ├── src/router/ — Vue Router + ├── src/types/ — TypeScript type definitions + └── src/style.css — Global styles + Tailwind utilities +``` + +### Data Paths (Server) + +- App data: `/var/lib/archipelago/{app-id}/` +- Secrets: `/var/lib/archipelago/secrets/{app-id}/` (encrypted) +- Frontend: `/opt/archipelago/web-ui/` +- Backend binary: `/usr/local/bin/archipelago` +- Systemd service: `/etc/systemd/system/archipelago.service` +- Nginx config: `/etc/nginx/sites-available/archipelago` + +## CRITICAL Workflow Rules + +### 1. NEVER Build Rust on macOS for Linux + +Always rsync source to the Linux dev server and build there. Building on macOS and copying the binary causes Exec format errors. + +```bash +# Deploy does this automatically: +./scripts/deploy-to-target.sh --live +``` + +### 2. Always Deploy After Changes + +After editing code (frontend, backend, scripts, or configs), deploy to the live server. Do not leave deployment to the user. + +### 3. Frontend Build Output Path + +Frontend builds to `web/dist/neode-ui/` — NOT `neode-ui/dist/`. + +### 4. Deploy-Test-Fix Loop + +1. Make the change +2. Deploy with `./scripts/deploy-to-target.sh --live` +3. Test at http://192.168.1.228 +4. If broken, fix and redeploy — repeat until working +5. End loop only when everything works + +### 5. SSH Access + +- **Primary**: `archipelago@192.168.1.228` — password: `EwPDR8q45l0Upx@` +- **Secondary**: `archipelago@192.168.1.198` +- Credentials stored in gitignored `scripts/deploy-config.sh` + +```bash +sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 +``` + +## Frontend Rules (Vue.js + TypeScript) + +### Component Standards + +- **Always** ` - - - - diff --git a/neode-ui/src/components/OnlineStatusPill.vue b/neode-ui/src/components/OnlineStatusPill.vue new file mode 100644 index 00000000..695b8bf2 --- /dev/null +++ b/neode-ui/src/components/OnlineStatusPill.vue @@ -0,0 +1,25 @@ + + + diff --git a/neode-ui/src/components/PWAInstallPrompt.vue b/neode-ui/src/components/PWAInstallPrompt.vue index 34d30f84..d9dcf5ae 100644 --- a/neode-ui/src/components/PWAInstallPrompt.vue +++ b/neode-ui/src/components/PWAInstallPrompt.vue @@ -24,7 +24,7 @@ diff --git a/neode-ui/src/components/PWAUpdatePrompt.vue b/neode-ui/src/components/PWAUpdatePrompt.vue index e7ed1e71..b43eaf4b 100644 --- a/neode-ui/src/components/PWAUpdatePrompt.vue +++ b/neode-ui/src/components/PWAUpdatePrompt.vue @@ -36,7 +36,7 @@ diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index a2da8090..76d1939f 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -27,7 +27,11 @@ const FOCUSABLE_SELECTOR = [ function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - (el) => !el.hasAttribute('disabled') && el.offsetParent !== null + (el) => + !el.hasAttribute('disabled') && + el.offsetParent !== null && + !el.hasAttribute('data-controller-ignore') && + !el.closest('[data-controller-ignore]') ) } diff --git a/neode-ui/src/composables/useLoginSounds.ts b/neode-ui/src/composables/useLoginSounds.ts index 59b6ece5..c1896a28 100644 --- a/neode-ui/src/composables/useLoginSounds.ts +++ b/neode-ui/src/composables/useLoginSounds.ts @@ -15,7 +15,7 @@ function getContext(): AudioContext | null { function ensureContext(): AudioContext | null { if (audioContext) return audioContext try { - const Ctx = window.AudioContext || (window as any).webkitAudioContext + const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext if (!Ctx) return null audioContext = new Ctx() return audioContext diff --git a/neode-ui/src/composables/useMarketplaceApp.ts b/neode-ui/src/composables/useMarketplaceApp.ts index 7f6da47c..fb61d2ff 100644 --- a/neode-ui/src/composables/useMarketplaceApp.ts +++ b/neode-ui/src/composables/useMarketplaceApp.ts @@ -1,24 +1,39 @@ import { ref } from 'vue' +export interface MarketplaceAppInfo { + id: string + title: string + version: string + icon: string + category: string + description: string | { short: string; long: string } + author: string + source: string + manifestUrl: string + url: string + repoUrl: string + s9pkUrl: string +} + // Simple in-memory store for the current marketplace app -const currentMarketplaceApp = ref(null) +const currentMarketplaceApp = ref(null) export function useMarketplaceApp() { - function setCurrentApp(app: any) { + function setCurrentApp(app: Partial & { id: string }) { // Create a clean, serializable copy currentMarketplaceApp.value = { id: app.id, - title: app.title, - version: app.version, - icon: app.icon, - category: app.category, - description: app.description, - author: app.author, - source: app.source, - manifestUrl: app.manifestUrl || app.s9pkUrl || app.url, - url: app.url || app.s9pkUrl || app.manifestUrl, - repoUrl: app.repoUrl, - s9pkUrl: app.s9pkUrl + title: app.title ?? '', + version: app.version ?? '', + icon: app.icon ?? '', + category: app.category ?? '', + description: app.description ?? '', + author: app.author ?? '', + source: app.source ?? '', + manifestUrl: app.manifestUrl || app.s9pkUrl || app.url || '', + url: app.url || app.s9pkUrl || app.manifestUrl || '', + repoUrl: app.repoUrl ?? '', + s9pkUrl: app.s9pkUrl ?? '', } } @@ -36,4 +51,3 @@ export function useMarketplaceApp() { clearCurrentApp } } - diff --git a/neode-ui/src/composables/useNavSounds.ts b/neode-ui/src/composables/useNavSounds.ts index 57c1578a..95774cb5 100644 --- a/neode-ui/src/composables/useNavSounds.ts +++ b/neode-ui/src/composables/useNavSounds.ts @@ -8,7 +8,7 @@ let audioContext: AudioContext | null = null function getContext(): AudioContext | null { if (audioContext) return audioContext try { - audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() + audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() return audioContext } catch { return null diff --git a/neode-ui/src/stores/app.ts b/neode-ui/src/stores/app.ts index ae3fc5c1..78195b91 100644 --- a/neode-ui/src/stores/app.ts +++ b/neode-ui/src/stores/app.ts @@ -90,7 +90,7 @@ export const useAppStore = defineStore('app', () => { } }) - wsClient.subscribe((update: any) => { + 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') @@ -256,19 +256,15 @@ export const useAppStore = defineStore('app', () => { return rpcClient.shutdownServer() } - async function getMetrics(): Promise { + async function getMetrics(): Promise> { return rpcClient.getMetrics() } // Marketplace actions - async function getMarketplace(url: string): Promise { + async function getMarketplace(url: string): Promise> { return rpcClient.getMarketplace(url) } - async function sideloadPackage(manifest: any, icon: string): Promise { - return rpcClient.sideloadPackage(manifest, icon) - } - return { // State data, @@ -303,7 +299,6 @@ export const useAppStore = defineStore('app', () => { shutdownServer, getMetrics, getMarketplace, - sideloadPackage, } }) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index b32fbe67..218d58d1 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -66,28 +66,56 @@ overflow-x: hidden; overflow-y: visible; } - + .glass-button { + position: relative; display: inline-flex; align-items: center; justify-content: center; - height: 48px; - min-height: 48px; - padding-block: 0 !important; - line-height: 48px; - background-color: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border: 1px solid rgba(255, 255, 255, 0.18); + padding-inline: 1.25rem; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.22); + border-radius: 0.75rem; + border: none; color: rgba(255, 255, 255, 0.9); + transition: all 0.3s ease; + } + + .glass-button::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 2px; + background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + } + + .glass-button:hover { + transform: translateY(-2px); + background: rgba(0, 0, 0, 0.35); + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.6), + inset 0 1px 0 rgba(255, 255, 255, 0.25); + } + + .glass-button:hover::before { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent); } .glass-button-sm { - min-height: 0 !important; - height: auto !important; - line-height: inherit; - padding-block: 0.375rem !important; + padding-block: 0.375rem; padding-inline: 0.75rem; + font-size: 0.875rem; } /* Toast - glassmorphic, top-right */ @@ -111,39 +139,10 @@ transform: translateX(1rem); } - /* Gradient containers - transparent to black */ - .gradient-card { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border: 1px solid rgba(255, 255, 255, 0.15); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); - border-radius: 1rem; - } - - .gradient-card-dark { - background: linear-gradient(180deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.9) 100%); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - border-radius: 1rem; - } - - .gradient-button { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(0, 0, 0, 0.8) 100%); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.95); - transition: all 0.3s ease; - } - - .gradient-button:hover { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(0, 0, 0, 0.9) 100%); - border-color: rgba(255, 255, 255, 0.3); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); - } + /* BANNED: gradient-card, gradient-card-dark, gradient-button + Use .glass-card or .path-option-card for containers. + Use .glass-button for all buttons. + These gradient styles break the clean glass aesthetic. */ /* Gradient border for logo badge */ .logo-gradient-border { @@ -198,7 +197,7 @@ -webkit-backdrop-filter: blur(40px); border-radius: 24px; border: 1px solid rgba(255, 255, 255, 0.06); - box-shadow: + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1); display: flex; @@ -236,8 +235,8 @@ border-radius: inherit; padding: 2px; background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent); - -webkit-mask: - linear-gradient(#fff 0 0) content-box, + -webkit-mask: + linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; @@ -248,7 +247,7 @@ .path-option-card svg { color: rgba(255, 255, 255, 0.85); transition: all 0.3s ease; - filter: + filter: drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3)) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8)) drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.6)); @@ -269,7 +268,7 @@ .path-option-card:hover svg { color: rgba(255, 255, 255, 1); - filter: + filter: drop-shadow(0 1px 2px rgba(255, 255, 255, 0.5)) drop-shadow(0 3px 6px rgba(0, 0, 0, 0.9)) drop-shadow(0 -1px 3px rgba(0, 0, 0, 0.7)); @@ -291,7 +290,7 @@ .path-option-card--selected svg { color: rgba(255, 255, 255, 1); - filter: + filter: drop-shadow(0 1px 2px rgba(255, 255, 255, 0.6)) drop-shadow(0 3px 8px rgba(0, 0, 0, 1)) drop-shadow(0 0 12px rgba(255, 255, 255, 0.3)); @@ -299,7 +298,7 @@ .path-option-card--selected h3 { color: rgba(255, 255, 255, 1); -} + } /* Action Buttons */ .path-action-button { @@ -415,7 +414,7 @@ body { font-family: 'Avenir Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background: #000 url('/assets/img/bg.jpg') center top / auto 100vh no-repeat fixed; + background: #000; color: white; min-height: 100vh; } diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts index 0f493a92..ec58d092 100644 --- a/neode-ui/src/types/api.ts +++ b/neode-ui/src/types/api.ts @@ -228,7 +228,7 @@ export namespace RR { export interface PatchOperation { op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test' path: string - value?: any + value?: unknown from?: string } diff --git a/neode-ui/src/utils/githubAppInfo.ts b/neode-ui/src/utils/githubAppInfo.ts index cbe4be96..3c4b68cd 100644 --- a/neode-ui/src/utils/githubAppInfo.ts +++ b/neode-ui/src/utils/githubAppInfo.ts @@ -100,7 +100,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis const releasesResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/releases/latest`) if (releasesResponse.ok) { const releasesData = await releasesResponse.json() - const asset = releasesData.assets?.find((a: any) => + const asset = releasesData.assets?.find((a: { name: string; browser_download_url: string }) => a.name.includes('icon') || a.name.includes('logo') ) if (asset) { diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 96cf87d6..53cde0e3 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -57,7 +57,7 @@ + + +
+ +
@@ -130,9 +135,8 @@ class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10" :class="{ 'glass-throw-main': showZoomIn }" > -
- +
@@ -309,7 +313,7 @@ import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router' import { useAppStore } from '../stores/app' import { useLoginTransitionStore } from '../stores/loginTransition' import AnimatedLogo from '@/components/AnimatedLogo.vue' -import AppSwitcher from '@/components/AppSwitcher.vue' +import OnlineStatusPill from '@/components/OnlineStatusPill.vue' import ControllerIndicator from '@/components/ControllerIndicator.vue' import { playDashboardLoadOomph } from '@/composables/useLoginSounds' diff --git a/neode-ui/src/views/Marketplace.vue b/neode-ui/src/views/Marketplace.vue index 022834f6..ad4c7515 100644 --- a/neode-ui/src/views/Marketplace.vue +++ b/neode-ui/src/views/Marketplace.vue @@ -79,16 +79,6 @@ - - @@ -177,7 +167,7 @@ data-controller-install-btn @click.stop="app.source === 'local' ? installApp(app) : installCommunityApp(app)" :disabled="installingApps.has(app.id)" - class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" + class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" > @@ -213,63 +203,6 @@ - - -
-
- - - -

Sideload Package

-

Install a package from an s9pk file URL or local path

- -
- - -
- -

{{ sideloadError }}

-

{{ sideloadSuccess }}

- - -
-

Examples:

-
    -
  • https://github.com/.../releases/download/v1.0.0/app.s9pk
  • -
  • /packages/myapp.s9pk (local file)
  • -
-
-
-
- -