feat: external iframes, container startup UX, release notes modal

- Add https: to CSP frame-src so external site iframes (BotFights,
  484 Kitchen, etc.) load without being blocked by Content-Security-Policy
- Show spinner + "Starting..." on marketplace cards for containers that
  are booting up, preventing users from re-installing running apps
- Add spinner to transitional state badges (starting/stopping/installing)
  on installed app cards in Apps view
- Add "What's New" button to Settings version card with release notes
  modal covering recent highlights in layman-friendly language

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-18 11:15:32 +00:00
parent e4089287a3
commit bccef62585
4 changed files with 115 additions and 22 deletions

View File

@ -17,7 +17,7 @@ server {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src 'self' http://$host:*; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# AIUI SPA (Chat mode iframe)
# Use =404 fallback instead of index.html to prevent serving HTML with wrong
@ -721,7 +721,7 @@ server {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src 'self' http://$host:*; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# AIUI SPA (Chat mode iframe)
location /aiui/ {

View File

@ -118,9 +118,19 @@
</p>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state)"
>
<svg
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting'"
class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<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>
{{ pkg.state }}
</span>
<span class="text-xs text-white/50">

View File

@ -177,37 +177,49 @@
</p>
<div class="flex gap-2 mt-auto">
<span
v-if="isInstalled(app.id)"
<!-- Installed & starting up (transitional state) -->
<span
v-if="isInstalled(app.id) && isStartingUp(app.id)"
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-200 text-sm font-medium text-center cursor-default flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<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>
{{ getInstalledState(app.id) === 'installing' ? 'Installing...' : 'Starting...' }}
</span>
<!-- Installed & ready -->
<span
v-else-if="isInstalled(app.id)"
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
>
{{ t('marketplace.alreadyInstalled') }}
</span>
>
{{ t('marketplace.alreadyInstalled') }}
</span>
<button
v-if="isInstalled(app.id)"
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
@click.stop="launchInstalledApp(app)"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
{{ t('common.launch') }}
</button>
<button
v-else-if="app.source === 'local' || app.dockerImage"
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
data-controller-install-btn
@click.stop="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
:disabled="installingApps.has(app.id)"
:disabled="installingApps.has(app.id)"
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"
>
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<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>
{{ installingApps.get(app.id)?.message || t('common.installing') }}
</span>
<span v-else>{{ t('common.install') }}</span>
>
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<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>
{{ installingApps.get(app.id)?.message || t('common.installing') }}
</span>
<span v-else>{{ t('common.install') }}</span>
</button>
<button
v-else
v-else-if="!isInstalled(app.id)"
disabled
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>
@ -640,6 +652,26 @@ function isInstalled(appId: string): boolean {
return aliases ? aliases.some((a) => a in installedPackages.value) : false
}
/** Get the package state for an installed app (checks aliases too). */
function getInstalledState(appId: string): string | null {
const pkg = installedPackages.value[appId]
if (pkg) return pkg.state
const aliases = INSTALLED_ALIASES[appId]
if (aliases) {
for (const a of aliases) {
const aliasPkg = installedPackages.value[a]
if (aliasPkg) return aliasPkg.state
}
}
return null
}
/** True if installed and currently in a transitional state (not yet ready). */
function isStartingUp(appId: string): boolean {
const state = getInstalledState(appId)
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
}
function launchInstalledApp(app: MarketplaceApp) {
appLauncher.openSession(app.id)
}

View File

@ -54,9 +54,59 @@
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('common.version') }}</p>
</div>
<p class="text-lg font-semibold text-white/95">{{ version }}</p>
<div class="flex items-center justify-between">
<p class="text-lg font-semibold text-white/95">{{ version }}</p>
<button
@click="showReleaseNotes = true"
class="glass-button px-3 py-1.5 text-xs"
>What's New</button>
</div>
</div>
<!-- Release Notes Modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showReleaseNotes" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="showReleaseNotes = false">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div @click.stop class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto">
<div class="flex items-start justify-between gap-4 mb-5">
<h3 class="text-xl font-semibold text-white">What's New in {{ version }}</h3>
<button @click="showReleaseNotes = false" class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors" aria-label="Close">
<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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="space-y-4 text-sm text-white/80">
<div>
<h4 class="text-white font-medium mb-1">Mesh Radio Connection</h4>
<p>Your LoRa mesh radio now connects reliably every time. If the USB port changes, the system automatically finds the device. A new "Connect" button lets you pick which radio to use.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Rock-Solid Containers</h4>
<p>Fixed crash loops that were causing apps to restart thousands of times. All containers now start cleanly and stay stable across reboots.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Smarter App Store</h4>
<p>Apps that are still starting up now show their progress instead of appearing as available to install again. No more accidentally re-installing running apps.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Security Hardening</h4>
<p>Backend services now run with strict sandboxing: read-only filesystem, restricted system calls, and least-privilege access. Your node is locked down by default.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Tor by Default</h4>
<p>Bitcoin and Lightning now route through Tor automatically for maximum privacy. Your node's network traffic is protected out of the box.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Off-Grid Bitcoin</h4>
<p>Receive Bitcoin block headers over mesh radio. A dead man's switch can broadcast your location to trusted contacts if you go silent. GPS sharing is opt-in only.</p>
</div>
</div>
<button @click="showReleaseNotes = false" class="glass-button w-full mt-6 py-2 text-sm">Close</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Session Card -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2">
<div class="flex items-center gap-3 mb-2">
@ -965,6 +1015,7 @@ async function saveServerName() {
}
const version = computed(() => store.serverInfo?.version || '0.0.0')
const showReleaseNotes = ref(false)
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
const torAddressFromRpc = ref<string | null>(null)
const serverTorAddress = computed(() => serverTorAddressFromStore.value || torAddressFromRpc.value)