archy/neode-ui/src/views/Apps.vue

812 lines
32 KiB
Vue
Raw Normal View History

2026-01-24 22:59:20 +00:00
<template>
2026-05-13 15:09:22 -04:00
<div class="apps-view pb-6">
<!-- Nav header -- tabs + categories + search -->
<div class="mb-4">
<!-- Desktop: page tabs + category tabs + search -->
<div ref="appsHeaderRef" class="hidden md:flex items-center gap-4 relative">
<div ref="appsPrimaryRef" class="flex-shrink-0">
<div class="mode-switcher hidden md:inline-flex">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
</div>
</div>
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && !collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && collapseCategories" class="segmented-select flex-shrink-0">
<label class="sr-only" for="apps-category-select">My Apps category</label>
<select
id="apps-category-select"
v-model="selectedCategory"
class="segmented-select-control"
>
<option
v-for="category in categoriesWithApps"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
</div>
<div ref="appsCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
<button
v-for="category in categoriesWithApps"
:key="category.id"
class="mode-switcher-btn"
type="button"
>
{{ category.name }}
</button>
</div>
<div class="app-header-search-wrap flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="min-w-0 flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<button
type="button"
class="sideload-icon-btn"
aria-label="Sideload app"
title="Sideload app"
@click="showSideload = true"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</button>
</div>
</div>
2026-01-24 22:59:20 +00:00
<!-- Mobile: search + sideload button (tabs handled by Dashboard.vue header) -->
<div class="md:hidden flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="min-w-0 flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<button
type="button"
class="sideload-icon-btn sideload-icon-btn-mobile"
aria-label="Sideload app"
title="Sideload app"
@click="showSideload = true"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</button>
</div>
</div>
<!-- Loading Skeleton -->
2026-05-06 09:23:57 -04:00
<div v-if="isLoadingApps" class="text-center py-16 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-lg font-semibold text-white mb-2">Loading apps</h3>
<p class="text-white/60 text-sm">Checking the latest app status before showing launch controls.</p>
</div>
</div>
<!-- Connection Error -->
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<div class="alert-error mb-4">{{ connectionError }}</div>
<button
@click="connectionError = ''; store.connectWebSocket()"
class="glass-button px-6 py-3 rounded-lg font-medium"
>
Retry Connection
</button>
</div>
</div>
<!-- Container scanner still warming up -->
<div v-else-if="isCheckingContainers" class="text-center py-16 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-xl font-semibold text-white mb-2">Checking containers</h3>
<p class="text-white/70">Archipelago is scanning installed apps. Your apps will appear here as soon as the container list is ready.</p>
</div>
</div>
<!-- Empty State -->
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
2026-01-24 22:59:20 +00:00
<div class="glass-card p-12 max-w-md mx-auto">
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
2026-01-24 22:59:20 +00:00
<RouterLink
to="/dashboard/marketplace"
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
>
{{ t('apps.browseAppStore') }}
2026-01-24 22:59:20 +00:00
</RouterLink>
</div>
</div>
<!-- No Results -->
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
</div>
<div v-if="marketplaceMatches.length > 0" class="mb-5">
<div class="flex items-center gap-3 mb-3">
<span class="discover-terminal-tag">app store</span>
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
<div class="flex-1 h-px bg-white/10"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<button
v-for="app in marketplaceMatches"
:key="app.id"
type="button"
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
@click="openMarketplaceResult(app)"
>
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-white truncate">{{ app.title }}</p>
<p class="text-xs text-white/50 truncate">Available in App Store</p>
</div>
</button>
</div>
</div>
<div
v-if="isUsingLastKnownPackages && filteredPackageEntries.length > 0"
class="mb-4 rounded-lg border border-yellow-400/20 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100/85 flex items-center gap-2"
>
<svg class="animate-spin h-4 w-4 flex-shrink-0 text-yellow-200/80" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Refreshing container state. Showing the last known app list until the scan finishes.</span>
</div>
<!-- Mobile: iPhone-style icon grid -->
<div class="md:hidden">
<AppIconGrid
:apps="filteredPackageEntries as [string, PackageDataEntry][]"
@go-to-app="goToApp"
/>
</div>
<!-- Desktop: Card grid -->
<div class="hidden md:grid grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<AppCard
v-for="([id, pkg], index) in filteredPackageEntries"
2026-01-24 22:59:20 +00:00
:key="id"
:id="id as string"
:pkg="pkg"
:index="index"
:show-stagger="showStagger"
:is-loading="!!actions.loadingActions.value[id as string]"
:is-installing="serverStore.isInstalling(id as string)"
:install-progress="serverStore.installingApps.get(id as string)"
:is-uninstalling="actions.uninstallingApps.has(id as string)"
@go-to-app="goToApp"
@launch="launchApp"
@start="actions.startApp"
@stop="actions.stopApp"
@restart="actions.restartApp"
release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification - core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh and image-recipe/configs/archipelago-doctor.{service,timer} via include_str! and sync to disk + enable the timer on every archipelago startup. Idempotent (content-hash compare), dev-box symlink guard keeps the git checkout untouched, best-effort (warn-only on failure) so bootstrap never blocks server readiness. Wired in main.rs as a background tokio task. - scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects when the rootless-netns has lost its pasta tap (container-to-container still works but outbound DNS/TCP fails) via an nsenter probe into aardvark-dns; with a two-probe 10s debounce to rule out transients and a host-precheck that bails out if the host itself is offline. When the rootless-netns is truly broken, does a graceful podman stop --all / start --all so pasta + aardvark-dns rebuild the netns from scratch. Bitcoin-knots and every other outbound container recover in one cycle. - core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs can reuse the existing systemd-run escape hatch. - apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned with the real container-specs.sh large-disk tune (4 GiB memory cap, cpu_limit: 0 so bitcoind can run -par=auto across every core). - neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button + Updating spinner to every app card that has available-update set. Wires through serverStore.updatePackage(id) — the same RPC the detail view already calls. common.update / common.updating i18n keys added in en.json and es.json. - core/archipelago/src/identity_manager.rs: add create_from_signing_key() that mirrors an existing Ed25519 key as a manager-level identity with a deterministic id (`node-<pubkey16>`). Idempotent across restarts, gets the hex-SVG master avatar. - core/archipelago/src/server.rs: the auto-create path on first boot now mirrors the node's own signing_key (seed-derived on onboarded installs) as a "Node" identity instead of generating a random "Default" keypair. Once this ships, the DID on the Web5 DID Status card (via node.did RPC), the Node entry on the Identities page (via identity.list), and the DID used for peer-to-peer connects (via server_info.pubkey) all resolve to the same seed-derived pubkey. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
@update="updateApp"
@show-uninstall="showUninstallModal"
/>
2026-01-24 22:59:20 +00:00
</div>
<AppsUninstallModal
:show="uninstallModal.show"
:app-title="uninstallModal.appTitle"
:uninstalling="actions.uninstalling.value"
@close="closeUninstallModal"
@confirm="onConfirmUninstall"
/>
<Transition name="fade">
<div
v-if="credentialModal.show"
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
@click.self="closeCredentialModal"
>
<div class="sideload-modal credential-modal">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">&times;</button>
</div>
<div class="credential-modal-body space-y-3">
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
</div>
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
</div>
</div>
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div
v-if="showSideload"
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
@click.self="closeSideload"
>
<form class="sideload-modal" @submit.prevent="submitSideload">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">&times;</button>
</div>
<div class="space-y-4">
<label class="block">
<span class="sideload-label">App ID</span>
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
</label>
<label class="block">
<span class="sideload-label">Docker image</span>
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="block">
<span class="sideload-label">Title</span>
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
</label>
<label class="block">
<span class="sideload-label">Port mapping</span>
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
</label>
</div>
<label class="block">
<span class="sideload-label">Description</span>
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
</label>
</div>
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
<p class="font-medium text-white/80 mb-2">Easy sources</p>
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
</div>
<div class="mt-5 flex gap-3">
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
{{ sideloading ? 'Installing...' : 'Install' }}
</button>
</div>
</form>
</div>
</Transition>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actions.actionError.value }}</span>
<button @click="actions.actionError.value = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
2026-01-24 22:59:20 +00:00
</div>
</template>
<script lang="ts">
// Module-level -- persists across component unmount/remount within same session
let appsAnimationDone = false
</script>
2026-01-24 22:59:20 +00:00
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { rpcClient } from '@/api/rpc-client'
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
import AppCard from './apps/AppCard.vue'
import AppIconGrid from './apps/AppIconGrid.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { resolveAppCredentials } from './apps/appCredentials'
import { useLastKnownPackages } from './apps/appPackageCache'
import { useAppsActions } from './apps/useAppsActions'
import { validateSideloadRequest } from './apps/sideloadValidation'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import {
2026-05-17 17:30:04 -04:00
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
2026-01-24 22:59:20 +00:00
const { t } = useI18n()
2026-01-24 22:59:20 +00:00
const router = useRouter()
const route = useRoute()
2026-01-24 22:59:20 +00:00
const store = useAppStore()
const serverStore = useServerStore()
const actions = useAppsActions()
const { setCurrentApp } = useMarketplaceApp()
const showSideload = ref(false)
const sideloading = ref(false)
const sideloadError = ref('')
const sideloadForm = ref({
id: '',
image: '',
title: '',
port: '',
description: '',
})
const credentialModal = ref({
show: false,
appId: '',
title: '',
description: '',
credentials: [] as AppCredential[],
copied: '',
})
2026-01-24 22:59:20 +00:00
// Only stagger-animate on first mount
const showStagger = !appsAnimationDone
// Tabs
2026-05-17 17:30:04 -04:00
const activeTab = ref<AppsTab>(
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
)
watch(() => route.query.tab, (tab) => {
activeTab.value = tab === 'websites' || tab === 'services' ? 'websites' : 'apps'
})
// Search (debounced)
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined
watch(searchQuery, (val) => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => { debouncedSearchQuery.value = val }, 150)
})
onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) })
// Category filter
const selectedCategory = ref('all')
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
const livePackages = computed(() => store.packages || {})
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false)
const {
packages: stablePackages,
isUsingLastKnownPackages,
} = useLastKnownPackages(livePackages, containersScanned)
// Merge real packages from store with web-only app bookmarks + installing placeholders
const packages = computed(() => {
const realPackages = stablePackages.value
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
// Inject placeholder entries for apps being installed that aren't in backend data yet
for (const [appId, progress] of serverStore.installingApps) {
if (!merged[appId]) {
merged[appId] = {
state: 'installing' as PackageState,
manifest: {
id: appId,
title: progress.title,
version: '',
description: { short: '', long: '' },
'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '',
'support-site': '', 'marketing-site': '', 'donation-url': null,
},
'static-files': { license: '', instructions: '', icon: '' },
}
}
}
return merged
})
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
const appsHeaderRef = ref<HTMLElement | null>(null)
const appsPrimaryRef = ref<HTMLElement | null>(null)
const appsCategoryProbeRef = ref<HTMLElement | null>(null)
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
appsHeaderRef,
appsPrimaryRef,
appsCategoryProbeRef,
144
)
const curatedApps = getCuratedAppList()
const marketplaceMatches = computed(() => {
const q = debouncedSearchQuery.value.trim().toLowerCase()
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
return curatedApps.filter(app => {
if (isInstalledInMyApps(app.id)) return false
return app.title?.toLowerCase().includes(q) ||
app.id.toLowerCase().includes(q) ||
app.author?.toLowerCase().includes(q) ||
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
}).slice(0, 6)
})
2026-05-06 09:23:57 -04:00
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
const isCheckingContainers = computed(() => (
store.hasLoadedInitialData &&
Object.keys(livePackages.value).length === 0 &&
!isUsingLastKnownPackages.value &&
sortedPackageEntries.value.length === 0 &&
!searchQuery.value &&
!containersScanned.value
))
2026-05-06 09:23:57 -04:00
// Connection error state
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
appsAnimationDone = true
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
2026-05-06 09:23:57 -04:00
if (!store.hasLoadedInitialData && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
}
}, 15000)
}
})
onBeforeUnmount(() => {
if (connectionTimer) clearTimeout(connectionTimer)
2026-01-24 22:59:20 +00:00
})
// Sorted entries: web-only first, then alphabetical by title
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
const filtered = filterEntriesForTab(entries, activeTab.value, selectedCategory.value)
return filtered.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(() => {
if (!debouncedSearchQuery.value) return sortedPackageEntries.value
const q = debouncedSearchQuery.value.toLowerCase()
return sortedPackageEntries.value.filter(([id, pkg]) =>
(pkg.manifest?.title ?? '').toLowerCase().includes(q) ||
(pkg.manifest?.description?.short ?? '').toLowerCase().includes(q) ||
id.toLowerCase().includes(q)
)
})
function isInstalledInMyApps(appId: string): boolean {
if (appId in packages.value) return true
const aliases = INSTALLED_ALIASES[appId]
return aliases ? aliases.some(alias => alias in packages.value) : false
}
function openMarketplaceResult(app: MarketplaceApp) {
setCurrentApp(app)
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
}
// Uninstall modal
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
function showUninstallModal(id: string, pkg: PackageDataEntry) {
uninstallModal.value = { show: true, appId: id, appTitle: pkg.manifest.title }
2026-01-24 22:59:20 +00:00
}
function closeUninstallModal() {
uninstallModal.value.show = false
2026-01-24 22:59:20 +00:00
}
async function onConfirmUninstall(deleteAppData: boolean) {
const { appId } = uninstallModal.value
// Close the modal immediately so the user can fire off concurrent
// uninstalls. Each AppCard surfaces its own live stage label while
// its uninstall is in flight.
uninstallModal.value.show = false
await actions.confirmUninstall(appId, { preserveData: !deleteAppData })
}
2026-01-24 22:59:20 +00:00
function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`).catch(() => {})
2026-01-24 22:59:20 +00:00
}
async function launchApp(id: string) {
const shown = await maybeShowCredentialsBeforeLaunch(id)
if (shown) return
launchAppNow(id)
}
function launchAppNow(id: string) {
2026-05-17 17:30:04 -04:00
const pkg = packages.value[id]
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (pkg && webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
return
}
2026-05-17 17:30:04 -04:00
if (pkg && isWebsitePackage(id, pkg)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
}
2026-05-17 17:30:04 -04:00
return
}
if (!isMobile && pkg && opensInTab(id)) {
2026-05-17 17:30:04 -04:00
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
}
useAppLauncherStore().openSession(id)
2026-01-24 22:59:20 +00:00
}
release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification - core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh and image-recipe/configs/archipelago-doctor.{service,timer} via include_str! and sync to disk + enable the timer on every archipelago startup. Idempotent (content-hash compare), dev-box symlink guard keeps the git checkout untouched, best-effort (warn-only on failure) so bootstrap never blocks server readiness. Wired in main.rs as a background tokio task. - scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects when the rootless-netns has lost its pasta tap (container-to-container still works but outbound DNS/TCP fails) via an nsenter probe into aardvark-dns; with a two-probe 10s debounce to rule out transients and a host-precheck that bails out if the host itself is offline. When the rootless-netns is truly broken, does a graceful podman stop --all / start --all so pasta + aardvark-dns rebuild the netns from scratch. Bitcoin-knots and every other outbound container recover in one cycle. - core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs can reuse the existing systemd-run escape hatch. - apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned with the real container-specs.sh large-disk tune (4 GiB memory cap, cpu_limit: 0 so bitcoind can run -par=auto across every core). - neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button + Updating spinner to every app card that has available-update set. Wires through serverStore.updatePackage(id) — the same RPC the detail view already calls. common.update / common.updating i18n keys added in en.json and es.json. - core/archipelago/src/identity_manager.rs: add create_from_signing_key() that mirrors an existing Ed25519 key as a manager-level identity with a deterministic id (`node-<pubkey16>`). Idempotent across restarts, gets the hex-SVG master avatar. - core/archipelago/src/server.rs: the auto-create path on first boot now mirrors the node's own signing_key (seed-derived on onboarded installs) as a "Node" identity instead of generating a random "Default" keypair. Once this ships, the DID on the Web5 DID Status card (via node.did RPC), the Node entry on the Identities page (via identity.list), and the DID used for peer-to-peer connects (via server_info.pubkey) all resolve to the same seed-derived pubkey. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
try {
const result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: id },
timeout: 5000,
})
const credentials = resolveAppCredentials(id, result)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
} catch {
const credentials = resolveAppCredentials(id, null)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
}
}
function closeCredentialModal() {
credentialModal.value.show = false
}
function continueCredentialLaunch() {
const id = credentialModal.value.appId
closeCredentialModal()
if (id) launchAppNow(id)
}
async function copyModalCredential(label: string, value: string) {
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
credentialModal.value.copied = label
}
release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification - core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh and image-recipe/configs/archipelago-doctor.{service,timer} via include_str! and sync to disk + enable the timer on every archipelago startup. Idempotent (content-hash compare), dev-box symlink guard keeps the git checkout untouched, best-effort (warn-only on failure) so bootstrap never blocks server readiness. Wired in main.rs as a background tokio task. - scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects when the rootless-netns has lost its pasta tap (container-to-container still works but outbound DNS/TCP fails) via an nsenter probe into aardvark-dns; with a two-probe 10s debounce to rule out transients and a host-precheck that bails out if the host itself is offline. When the rootless-netns is truly broken, does a graceful podman stop --all / start --all so pasta + aardvark-dns rebuild the netns from scratch. Bitcoin-knots and every other outbound container recover in one cycle. - core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs can reuse the existing systemd-run escape hatch. - apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned with the real container-specs.sh large-disk tune (4 GiB memory cap, cpu_limit: 0 so bitcoind can run -par=auto across every core). - neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button + Updating spinner to every app card that has available-update set. Wires through serverStore.updatePackage(id) — the same RPC the detail view already calls. common.update / common.updating i18n keys added in en.json and es.json. - core/archipelago/src/identity_manager.rs: add create_from_signing_key() that mirrors an existing Ed25519 key as a manager-level identity with a deterministic id (`node-<pubkey16>`). Idempotent across restarts, gets the hex-SVG master avatar. - core/archipelago/src/server.rs: the auto-create path on first boot now mirrors the node's own signing_key (seed-derived on onboarded installs) as a "Node" identity instead of generating a random "Default" keypair. Once this ships, the DID on the Web5 DID Status card (via node.did RPC), the Node entry on the Identities page (via identity.list), and the DID used for peer-to-peer connects (via server_info.pubkey) all resolve to the same seed-derived pubkey. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
async function updateApp(id: string) {
try {
await serverStore.updatePackage(id)
} catch (err) {
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
}
}
function closeSideload() {
if (sideloading.value) return
showSideload.value = false
sideloadError.value = ''
}
function inferPortMapping(image: string): string {
const lower = image.toLowerCase()
if (lower.includes('excalidraw')) return '3009:80'
if (lower.includes('stirling')) return '3011:8080'
if (lower.includes('freshrss')) return '3012:80'
if (lower.includes('wallabag')) return '3013:80'
if (lower.includes('hedgedoc')) return '3014:3000'
if (lower.includes('cyberchef')) return '3015:80'
if (lower.includes('mealie')) return '3016:9000'
if (lower.includes('pairdrop')) return '3017:3000'
return ''
}
async function submitSideload() {
const id = sideloadForm.value.id.trim().toLowerCase()
const image = sideloadForm.value.image.trim()
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
return
}
if (!image || !image.includes('/')) {
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
return
}
const validationError = validateSideloadRequest(id, port, store.packages)
if (validationError) {
sideloadError.value = validationError
return
}
sideloading.value = true
sideloadError.value = ''
const containerConfig: Record<string, unknown> = {}
containerConfig.title = title
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
if (port) containerConfig.ports = [port]
try {
serverStore.setInstallProgress(id, {
id,
title,
status: 'downloading',
progress: 2,
message: 'Sideload queued...',
attempt: 0,
})
await rpcClient.call({
method: 'package.install',
params: {
id,
dockerImage: image,
version: 'sideload',
containerConfig,
},
2026-05-19 14:29:20 -04:00
timeout: 600000,
})
closeSideload()
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
} catch (err) {
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
serverStore.setInstallProgress(id, {
id,
title,
status: 'error',
progress: 0,
message: sideloadError.value,
attempt: 0,
})
} finally {
sideloading.value = false
}
}
2026-01-24 22:59:20 +00:00
</script>
<style scoped>
.sideload-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.78);
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.sideload-icon-btn:hover,
.sideload-icon-btn:focus-visible {
border-color: rgba(255, 255, 255, 0.38);
background: rgba(255, 255, 255, 0.15);
color: white;
}
.sideload-icon-btn-mobile {
width: 48px;
height: 48px;
}
.sideload-modal {
width: 100%;
max-width: 34rem;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.5rem 1.5rem 0 0;
background: rgba(8, 10, 18, 0.94);
padding: 1.25rem;
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
}
.sideload-close-btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.75rem;
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
}
.sideload-label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.62);
}
.sideload-input {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
padding: 0.75rem 0.9rem;
color: white;
outline: none;
}
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
.credential-modal {
display: flex;
flex-direction: column;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
border-radius: 1.25rem;
padding-bottom: 1.25rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
.credential-modal-body {
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.credential-modal-actions {
flex-shrink: 0;
}
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
}
</style>