fix: container DNS, nginx chown, onboarding guard, seed UX, install flow

Backend:
- Add --add-host host.containers.internal:host-gateway to LND and Bitcoin
  Knots containers (fixes DNS resolution failure in rootless podman)
- Add --user 0:0 and DAC_OVERRIDE to nginx UI sidecar containers
  (fixes chown crash in rootless podman for bitcoin-ui, electrs-ui, lnd-ui)
- Add hostadd to Rust Podman API client for web UI container installs
- Add Chromium privacy flags to kiosk launcher (disable telemetry)

Frontend:
- Fix onboarding reset on raw IP visits (trust localStorage as first-class
  signal, skip boot screen when server is up but not onboarded)
- Fix seed regression: persist challenge indices in sessionStorage so going
  back from Verify doesn't change which words are asked
- Remove glass container from seed Generate/Verify/Restore screens
- Add Back button to Restore from Seed screen
- Replace Network card: Tor (purple), VPN status (orange), Bitcoin sync (orange)
- Add ElectrumX to curated app list with correct .webp icon
- Install flow: navigate to My Apps immediately with toast, hide
  installed/installing apps from marketplace and discover views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-01 13:06:57 +01:00
parent 19587687b4
commit dad184cfc6
14 changed files with 3874 additions and 233 deletions

View File

@ -310,6 +310,7 @@ impl PodmanClient {
"portmappings": port_mappings,
"mounts": mounts,
"env": env_map,
"hostadd": ["host.containers.internal:host-gateway"],
"devices": manifest.app.devices.iter().map(|d| {
serde_json::json!({"path": d})
}).collect::<Vec<_>>(),

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,11 @@ while true; do
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
--disable-breakpad \
--disable-metrics \
--disable-metrics-reporting \
--metrics-recording-only \
--disable-domain-reliability \
--js-flags="--max-old-space-size=128" \
--user-data-dir=/home/archipelago/.config/chromium-kiosk
sleep 3

View File

@ -19,9 +19,11 @@ async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T
}
export async function isOnboardingComplete(): Promise<boolean> {
// localStorage is set on completion and survives backend restarts/resets
if (localStorage.getItem('neode_onboarding_complete') === '1') return true
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
if (result !== null) return result
return localStorage.getItem('neode_onboarding_complete') === '1'
return false
}
export async function completeOnboarding(): Promise<void> {

View File

@ -144,6 +144,7 @@ import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import AppGrid from './discover/AppGrid.vue'
@ -427,9 +428,13 @@ function startInstallPolling(appId: string, statusMessage: string) {
}, 1000)
}
const toast = useToast()
async function installApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
router.push('/dashboard/apps').catch(() => {})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
@ -446,6 +451,8 @@ async function installApp(app: MarketplaceApp) {
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
router.push('/dashboard/apps').catch(() => {})
try {
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
await rpcClient.call({ method: 'package.install', params: { id: app.id, dockerImage: app.dockerImage, version: app.version }, timeout: 180000 })

View File

@ -140,16 +140,16 @@
</div>
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="servicesDotColor"></div><span class="text-sm text-white/80">{{ t('home.servicesStatus') }}</span></div>
<span class="text-sm font-medium" :class="servicesStatusColor">{{ servicesStatusText }}</span>
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="torConnected ? 'bg-purple-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Tor</span></div>
<span class="text-sm font-medium" :class="torConnected ? 'text-purple-400' : 'text-white/40'">{{ torConnected ? 'Connected' : 'Offline' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="connectivityDotColor"></div><span class="text-sm text-white/80">{{ t('home.connectivity') }}</span></div>
<span class="text-sm font-medium" :class="connectivityColor">{{ connectivityText }}</span>
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'Connected' : 'Not configured' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full bg-blue-400"></div><span class="text-sm text-white/80">{{ t('home.runningApps') }}</span></div>
<span class="text-sm text-white/80 font-medium">{{ runningCount }}</span>
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
<span class="text-sm font-medium" :class="systemStats.bitcoinAvailable ? 'text-orange-400' : 'text-white/40'">{{ bitcoinSyncDisplay }}</span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
@ -306,13 +306,20 @@ const quickLaunchApps = [
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
]
const servicesAllRunning = computed(() => appCount.value > 0 && runningCount.value === appCount.value)
const servicesStatusText = computed(() => appCount.value === 0 ? t('home.noApps') : servicesAllRunning.value ? t('home.allRunning') : `${runningCount.value}/${appCount.value} ${t('home.runningLabel')}`)
const servicesStatusColor = computed(() => appCount.value === 0 ? 'text-white/60' : servicesAllRunning.value ? 'text-green-400' : 'text-yellow-400')
const servicesDotColor = computed(() => appCount.value === 0 ? 'bg-white/40' : servicesAllRunning.value ? 'bg-green-400' : 'bg-yellow-400')
const connectivityText = computed(() => store.isConnected ? t('common.connected') : t('common.disconnected'))
const connectivityColor = computed(() => store.isConnected ? 'text-green-400' : 'text-red-400')
const connectivityDotColor = computed(() => store.isConnected ? 'bg-green-400' : 'bg-red-400')
// Network card data
const torConnected = computed(() => {
const torAddr = store.data?.['server-info']?.['tor-address']
return !!torAddr && torAddr.length > 0
})
const vpnConnected = computed(() => {
const pkg = packages.value['tailscale']
return !!pkg && pkg.state === PackageState.Running
})
const bitcoinSyncDisplay = computed(() => {
if (!systemStats.bitcoinAvailable) return 'Not running'
if (systemStats.bitcoinSyncPercent >= 99.9) return 'Synced'
return `${systemStats.bitcoinSyncPercent.toFixed(1)}%`
})
// Quick Start
const quickStartDismissed = ref(false)

View File

@ -116,6 +116,7 @@ import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
import {
@ -135,6 +136,7 @@ const { t } = useI18n()
const showStagger = !marketplaceAnimationDone
const { setCurrentApp } = useMarketplaceApp()
const appLauncher = useAppLauncherStore()
const toast = useToast()
// Category state read initial value from query param (set by Discover page navigation)
const selectedCategory = ref((route.query.category as string) || 'all')
@ -300,11 +302,8 @@ const filteredApps = computed(() => {
)
}
apps.sort((a, b) => {
const aInstalled = isInstalled(a.id) ? 1 : 0
const bInstalled = isInstalled(b.id) ? 1 : 0
return aInstalled - bInstalled
})
// Hide installed and installing apps from marketplace they belong in My Apps
apps = apps.filter(app => !isInstalled(app.id) && !installingApps.has(app.id))
return apps
})
@ -433,6 +432,10 @@ async function installApp(app: MarketplaceApp) {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
})
// Navigate to My Apps immediately and show toast
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
router.push('/dashboard/apps').catch(() => {})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
@ -457,6 +460,10 @@ async function installCommunityApp(app: MarketplaceApp) {
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
})
// Navigate to My Apps immediately and show toast
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
router.push('/dashboard/apps').catch(() => {})
try {
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })

View File

@ -1,6 +1,6 @@
<template>
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
<div class="max-w-[800px] w-full max-h-full relative z-10 onb-scroll-container flex flex-col">
<!-- Header -->
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">

View File

@ -1,6 +1,6 @@
<template>
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
<div class="max-w-[800px] w-full max-h-full relative z-10 onb-scroll-container flex flex-col">
<!-- Header -->
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
@ -69,7 +69,14 @@
</div>
<!-- Fixed Footer -->
<div class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
<span
v-if="!restored"
@click="goBack"
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
>
Back
</span>
<button
v-if="!restored"
@click="restore"
@ -116,6 +123,11 @@ const restoredDid = ref('')
const allFilled = computed(() => seedWords.value.every(w => w.trim().length > 0))
function goBack() {
playNavSound('action')
router.push('/onboarding/path').catch(() => {})
}
onMounted(() => {
nextTick(() => {
setTimeout(() => wordInputs.value[0]?.focus({ preventScroll: true }), 300)

View File

@ -1,6 +1,6 @@
<template>
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
<div class="max-w-[800px] w-full max-h-full relative z-10 onb-scroll-container flex flex-col">
<!-- Header (hidden after verification) -->
<div v-if="!verified" class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
@ -168,7 +168,24 @@ onMounted(() => {
return
}
words.value = JSON.parse(stored)
challengeIndices.value = pickRandomIndices(4, 24)
// Restore challenge indices so going back and returning asks the same words
const savedIndices = sessionStorage.getItem('_seed_challenge_indices')
if (savedIndices) {
try {
const parsed = JSON.parse(savedIndices)
if (Array.isArray(parsed) && parsed.length === 4) {
challengeIndices.value = parsed
} else {
challengeIndices.value = pickRandomIndices(4, 24)
}
} catch {
challengeIndices.value = pickRandomIndices(4, 24)
}
} else {
challengeIndices.value = pickRandomIndices(4, 24)
}
sessionStorage.setItem('_seed_challenge_indices', JSON.stringify(challengeIndices.value))
nextTick(() => {
setTimeout(() => inputRefs.value[0]?.focus({ preventScroll: true }), 300)
@ -232,6 +249,7 @@ async function verify() {
localStorage.setItem('neode_did', res.did)
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
sessionStorage.removeItem('_seed_words')
sessionStorage.removeItem('_seed_challenge_indices')
nextTick(() => {
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)

View File

@ -124,10 +124,12 @@ onMounted(async () => {
proceedToApp()
return
}
log('server up + NOT onboarded → boot screen')
log('server up + NOT onboarded → onboarding intro')
router.replace('/onboarding/intro').catch(() => {})
return
}
// Server not ready OR first boot show boot screen
// Server not ready show boot screen (waiting for backend)
showBootScreen.value = true
})
</script>

View File

@ -24,7 +24,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'nostr-rs-relay', title: 'Nostr Relay', version: '0.9.0', category: 'nostr', description: 'Your own Nostr relay. Store events locally, relay for friends, publish over Tor.', icon: '/assets/img/app-icons/nostr-rs-relay.svg', author: 'scsiblade', dockerImage: `${R}/nostr-rs-relay:0.9.0`, repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/' },
{ id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: 'localhost/indeedhub:latest', repoUrl: 'https://github.com/indeedhub/indeedhub' },

View File

@ -303,6 +303,17 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/immich-app/immich'
},
{
id: 'electrumx',
title: 'ElectrumX',
version: '1.18.0',
description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.',
icon: '/assets/img/app-icons/electrumx.webp',
author: 'Luke Childs',
dockerImage: `${REGISTRY}/electrumx:v1.18.0`,
manifestUrl: undefined,
repoUrl: 'https://github.com/spesmilo/electrumx'
},
{
id: 'filebrowser',
title: 'File Browser',

View File

@ -368,6 +368,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped \
--health-cmd="bitcoin-cli -rpcuser=\$BITCOIN_RPC_USER -rpcpassword=\$BITCOIN_RPC_PASS getblockchaininfo || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit bitcoin-knots) --network archy-net \
--add-host host.containers.internal:host-gateway \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8332:8332 -p 8333:8333 -p 28332:28332 -p 28333:28333 \
@ -483,21 +484,21 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrs-ui; then
log "Starting ElectrumX UI from pre-built image..."
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
--health-cmd="curl -sf http://localhost:80/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
--user 0:0 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
localhost/electrs-ui:local 2>>"$LOG" || \
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
--health-cmd="curl -sf http://localhost:80/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
--user 0:0 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
electrs-ui:local 2>>"$LOG" || true
elif [ -d /opt/archipelago/docker/electrs-ui ]; then
log "Building and starting ElectrumX UI from source..."
$DOCKER build -t electrs-ui:local /opt/archipelago/docker/electrs-ui 2>>"$LOG" && \
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
--health-cmd="curl -sf http://localhost:80/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
--user 0:0 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
electrs-ui:local 2>>"$LOG" || true
else
log "ElectrumX UI: no image or source found, skipping"
@ -607,6 +608,7 @@ LNDCONF
$DOCKER run -d --name lnd --restart unless-stopped \
--health-cmd="curl -sf --insecure https://localhost:8080/v1/getinfo || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit lnd) --network archy-net \
--add-host host.containers.internal:host-gateway \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE --cap-add NET_RAW \
--security-opt no-new-privileges:true \
-p 9735:9735 -p 10009:10009 -p 8080:8080 \
@ -994,23 +996,23 @@ for ui in bitcoin-ui lnd-ui; do
log "Starting $ui from pre-built image..."
IMG=$($DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep "$ui" | head -1)
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped --memory=$(mem_limit "$CONTAINER_NAME") $NET_ARG \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
--user 0:0 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
"$IMG" 2>>"$LOG" || true
elif [ -d "/opt/archipelago/docker/$ui" ]; then
log "Building $ui from source (/opt/archipelago/docker/$ui)..."
if $DOCKER build -t "$ui:local" "/opt/archipelago/docker/$ui" 2>>"$LOG"; then
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped --memory=$(mem_limit "$CONTAINER_NAME") $NET_ARG \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
--user 0:0 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
"$ui:local" 2>>"$LOG" || true
fi
elif [ -d "/home/archipelago/archy/docker/$ui" ]; then
log "Building $ui from source (/home/archipelago/archy/docker/$ui)..."
if $DOCKER build -t "$ui:local" "/home/archipelago/archy/docker/$ui" 2>>"$LOG"; then
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped --memory=$(mem_limit "$CONTAINER_NAME") $NET_ARG \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
--user 0:0 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
"$ui:local" 2>>"$LOG" || true
fi
else