feat: add container security hardening and Fedimint setup wizard
Add --cap-drop=ALL, --security-opt=no-new-privileges:true to all non-privileged containers. Per-app capability grants for apps needing CHOWN/SETUID/SETGID. Read-only root filesystem with tmpfs for compatible apps (searxng, grafana, uptime-kuma, filebrowser, photoprism, vaultwarden). Add Fedimint "Create a Community" goal with 4-step wizard. Fix deploy script cp -rf for audio directory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
da3bf44cdb
commit
0bc7251e22
@ -103,12 +103,12 @@ After getting Claude Max OAuth working on the live server, hardening the deploy
|
|||||||
- **Change**: Add "Create DID" button calling backend DID RPC endpoint. Display DID once created. Show Nostr relay status. Store DID in localStorage until backend persistence ready.
|
- **Change**: Add "Create DID" button calling backend DID RPC endpoint. Display DID once created. Show Nostr relay status. Store DID in localStorage until backend persistence ready.
|
||||||
- **Verify**: Web5 page, Create DID, DID displayed
|
- **Verify**: Web5 page, Create DID, DID displayed
|
||||||
|
|
||||||
### Task 18: Fedimint setup wizard
|
### Task 18: Fedimint setup wizard [DONE]
|
||||||
- **Files**: `neode-ui/src/data/goals.ts`
|
- **Files**: `neode-ui/src/data/goals.ts`
|
||||||
- **Change**: Add `setup-fedimint` goal with steps: (1) Install Fedimint, (2) Access Guardian UI (port 8175), (3) Configure federation name, (4) Share invite code. Use "Create a Community" vernacular. Each step checks app state.
|
- **Change**: Add `setup-fedimint` goal with steps: (1) Install Fedimint, (2) Access Guardian UI (port 8175), (3) Configure federation name, (4) Share invite code. Use "Create a Community" vernacular. Each step checks app state.
|
||||||
- **Verify**: New Fedimint goal appears in goals, wizard steps work
|
- **Verify**: New Fedimint goal appears in goals, wizard steps work
|
||||||
|
|
||||||
### Task 19: Security hardening audit
|
### Task 19: Security hardening audit [DONE]
|
||||||
- **Files**: `core/archipelago/src/api/rpc/package.rs`
|
- **Files**: `core/archipelago/src/api/rpc/package.rs`
|
||||||
- **Change**: Add security flags to default container `run_args`: `--read-only` (with tmpfs for /tmp), `--cap-drop=ALL`, `--security-opt=no-new-privileges:true`. Create per-app capability mapping for apps that need specific caps.
|
- **Change**: Add security flags to default container `run_args`: `--read-only` (with tmpfs for /tmp), `--cap-drop=ALL`, `--security-opt=no-new-privileges:true`. Create per-app capability mapping for apps that need specific caps.
|
||||||
- **Verify**: Install an app, `podman inspect` shows security constraints
|
- **Verify**: Install an app, `podman inspect` shows security constraints
|
||||||
|
|||||||
@ -122,6 +122,27 @@ impl RpcHandler {
|
|||||||
run_args.push("--network=archy-net");
|
run_args.push("--network=archy-net");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security hardening (skip for privileged containers like Tailscale)
|
||||||
|
let security_caps: Vec<String> = if !is_tailscale {
|
||||||
|
get_app_capabilities(package_id)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id);
|
||||||
|
|
||||||
|
if !is_tailscale {
|
||||||
|
run_args.push("--cap-drop=ALL");
|
||||||
|
run_args.push("--security-opt=no-new-privileges:true");
|
||||||
|
for cap in &security_caps {
|
||||||
|
run_args.push(cap);
|
||||||
|
}
|
||||||
|
if readonly_compatible {
|
||||||
|
run_args.push("--read-only");
|
||||||
|
run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m");
|
||||||
|
run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create data directories if they don't exist
|
// Create data directories if they don't exist
|
||||||
for volume in &volumes {
|
for volume in &volumes {
|
||||||
if let Some(host_path) = volume.split(':').next() {
|
if let Some(host_path) = volume.split(':').next() {
|
||||||
@ -776,6 +797,51 @@ fn is_valid_docker_image(image: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
||||||
|
/// Most apps need CHOWN/SETUID/SETGID for internal user switching.
|
||||||
|
fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||||
|
match app_id {
|
||||||
|
// Apps that need user switching and file ownership changes
|
||||||
|
"nextcloud" | "homeassistant" | "home-assistant" | "btcpay-server" | "btcpayserver"
|
||||||
|
| "jellyfin" | "onlyoffice" | "onlyoffice-documentserver" | "portainer" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||||
|
],
|
||||||
|
// Nginx Proxy Manager needs to bind low ports
|
||||||
|
"nginx-proxy-manager" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
],
|
||||||
|
// Bitcoin and Lightning need file ownership ops
|
||||||
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
],
|
||||||
|
// Grafana runs as specific UID (472)
|
||||||
|
"grafana" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
],
|
||||||
|
// Minimal apps (searxng, filebrowser, uptime-kuma, etc.) need no extra caps
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apps safe to run with --read-only root filesystem.
|
||||||
|
/// These work correctly with volume mounts + tmpfs for /tmp and /run.
|
||||||
|
fn is_readonly_compatible(app_id: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
app_id,
|
||||||
|
"searxng" | "grafana" | "uptime-kuma" | "filebrowser" | "photoprism" | "vaultwarden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get app-specific configuration
|
/// Get app-specific configuration
|
||||||
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
|
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
|
||||||
fn get_app_config(
|
fn get_app_config(
|
||||||
|
|||||||
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.qmc1lepk3f"
|
"revision": "0.0ddc43l70qk"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const currentName = ref('')
|
|||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const currentTime = ref(0)
|
const currentTime = ref(0)
|
||||||
const duration = ref(0)
|
const duration = ref(0)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
function play(src: string, name: string) {
|
function play(src: string, name: string) {
|
||||||
if (!audio.value) {
|
if (!audio.value) {
|
||||||
@ -15,6 +16,7 @@ function play(src: string, name: string) {
|
|||||||
})
|
})
|
||||||
audio.value.addEventListener('loadedmetadata', () => {
|
audio.value.addEventListener('loadedmetadata', () => {
|
||||||
duration.value = audio.value?.duration ?? 0
|
duration.value = audio.value?.duration ?? 0
|
||||||
|
error.value = null
|
||||||
})
|
})
|
||||||
audio.value.addEventListener('ended', () => {
|
audio.value.addEventListener('ended', () => {
|
||||||
playing.value = false
|
playing.value = false
|
||||||
@ -24,8 +26,14 @@ function play(src: string, name: string) {
|
|||||||
})
|
})
|
||||||
audio.value.addEventListener('play', () => {
|
audio.value.addEventListener('play', () => {
|
||||||
playing.value = true
|
playing.value = true
|
||||||
|
error.value = null
|
||||||
|
})
|
||||||
|
audio.value.addEventListener('error', () => {
|
||||||
|
playing.value = false
|
||||||
|
error.value = 'Could not play audio. File Browser may not be running.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
error.value = null
|
||||||
|
|
||||||
if (currentSrc.value === src && playing.value) {
|
if (currentSrc.value === src && playing.value) {
|
||||||
audio.value.pause()
|
audio.value.pause()
|
||||||
@ -78,5 +86,6 @@ export function useAudioPlayer() {
|
|||||||
duration,
|
duration,
|
||||||
progress,
|
progress,
|
||||||
currentSrc,
|
currentSrc,
|
||||||
|
error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,21 +28,25 @@ export function useMobileBackButton() {
|
|||||||
|
|
||||||
// Try to find the mobile tab bar element
|
// Try to find the mobile tab bar element
|
||||||
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
||||||
if (tabBar) {
|
if (tabBar && tabBar.offsetHeight > 0) {
|
||||||
tabBarHeight.value = tabBar.offsetHeight
|
tabBarHeight.value = tabBar.offsetHeight
|
||||||
} else {
|
return
|
||||||
// Fallback: read from CSS variable if available
|
}
|
||||||
const cssVar = getComputedStyle(document.documentElement)
|
|
||||||
.getPropertyValue('--mobile-tab-bar-height')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
if (cssVar) {
|
// Fallback: read from CSS variable if available
|
||||||
const height = parseFloat(cssVar)
|
const cssVar = getComputedStyle(document.documentElement)
|
||||||
if (!isNaN(height)) {
|
.getPropertyValue('--mobile-tab-bar-height')
|
||||||
tabBarHeight.value = height
|
.trim()
|
||||||
}
|
|
||||||
|
if (cssVar) {
|
||||||
|
const height = parseFloat(cssVar)
|
||||||
|
if (!isNaN(height) && height > 0) {
|
||||||
|
tabBarHeight.value = height
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final fallback: keep current value (don't reset to 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@ -189,6 +189,48 @@ export const GOALS: GoalDefinition[] = [
|
|||||||
estimatedTime: '~40 min + sync time',
|
estimatedTime: '~40 min + sync time',
|
||||||
difficulty: 'intermediate',
|
difficulty: 'intermediate',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'setup-fedimint',
|
||||||
|
title: 'Create a Community',
|
||||||
|
subtitle: 'Start a Fedimint federation for private, scalable Bitcoin',
|
||||||
|
icon: 'community',
|
||||||
|
category: 'community',
|
||||||
|
requiredApps: ['bitcoin-knots', 'fedimint'],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'install-bitcoin',
|
||||||
|
title: 'Install Bitcoin Node',
|
||||||
|
description: 'Bitcoin Knots provides the base layer that Fedimint connects to for on-chain transactions and consensus.',
|
||||||
|
appId: 'bitcoin-knots',
|
||||||
|
action: 'install',
|
||||||
|
isAutomatic: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'install-fedimint',
|
||||||
|
title: 'Install Fedimint',
|
||||||
|
description: 'Fedimint is a federated Bitcoin mint. Guardians collectively manage funds using threshold signatures — no single point of failure.',
|
||||||
|
appId: 'fedimint',
|
||||||
|
action: 'install',
|
||||||
|
isAutomatic: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'configure-guardian',
|
||||||
|
title: 'Set Up Guardian UI',
|
||||||
|
description: 'Open the Guardian UI (port 8175) to configure your federation name, set the guardian threshold, and initialize the mint.',
|
||||||
|
action: 'configure',
|
||||||
|
isAutomatic: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share-invite',
|
||||||
|
title: 'Share Invite Code',
|
||||||
|
description: 'Generate and share your federation invite code with community members so they can join and start using ecash.',
|
||||||
|
action: 'info',
|
||||||
|
isAutomatic: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
estimatedTime: '~30 min + sync time',
|
||||||
|
difficulty: 'intermediate',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'create-identity',
|
id: 'create-identity',
|
||||||
title: 'Create My Identity',
|
title: 'Create My Identity',
|
||||||
|
|||||||
@ -180,6 +180,13 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On mobile, leave room for close button + tab bar below AIUI */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.chat-iframe-mobile {
|
||||||
|
padding-bottom: calc(var(--mobile-tab-bar-height, 72px) + 52px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Chat placeholder (no AIUI URL) */
|
/* Chat placeholder (no AIUI URL) */
|
||||||
.chat-placeholder {
|
.chat-placeholder {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -1175,6 +1182,16 @@ html:has(body.video-background-active)::before {
|
|||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile floating back/close button (always 8px above tab bar) ──── */
|
||||||
|
.mobile-back-btn {
|
||||||
|
position: fixed;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(var(--mobile-tab-bar-height, 72px) + 8px);
|
||||||
|
z-index: 40;
|
||||||
|
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Cloud Audio Player (mini bar) ──── */
|
/* ── Cloud Audio Player (mini bar) ──── */
|
||||||
|
|
||||||
.cloud-audio-player {
|
.cloud-audio-player {
|
||||||
@ -1194,8 +1211,9 @@ html:has(body.video-background-active)::before {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2.25rem;
|
width: 2.75rem;
|
||||||
height: 2.25rem;
|
height: 2.75rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border: none;
|
border: none;
|
||||||
@ -1203,6 +1221,7 @@ html:has(body.video-background-active)::before {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
.cloud-audio-player-btn:hover {
|
.cloud-audio-player-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export interface GoalDefinition {
|
|||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
icon: string
|
icon: string
|
||||||
category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup'
|
category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup' | 'community'
|
||||||
requiredApps: string[]
|
requiredApps: string[]
|
||||||
steps: GoalStep[]
|
steps: GoalStep[]
|
||||||
estimatedTime: string
|
estimatedTime: string
|
||||||
|
|||||||
@ -11,11 +11,7 @@
|
|||||||
<!-- Mobile Full-Width Back Button -->
|
<!-- Mobile Full-Width Back Button -->
|
||||||
<button
|
<button
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||||
:style="{
|
|
||||||
bottom: bottomPosition,
|
|
||||||
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
@ -432,12 +428,9 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import { PackageState } from '../types/api'
|
import { PackageState } from '../types/api'
|
||||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
import { dummyApps } from '../utils/dummyApps'
|
import { dummyApps } from '../utils/dummyApps'
|
||||||
|
|
||||||
const { bottomPosition } = useMobileBackButton()
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-fullscreen">
|
<div class="chat-fullscreen">
|
||||||
<!-- Close button + connection indicator (desktop only) -->
|
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||||
<div class="chat-mode-pill hidden md:flex">
|
<div class="chat-mode-pill hidden md:flex">
|
||||||
<button class="chat-close-btn" @click="closeChat">
|
<button class="chat-close-btn" @click="closeChat">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -15,18 +15,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile close button (bottom, thumb-reachable) -->
|
|
||||||
<button
|
|
||||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
|
||||||
:style="{ bottom: bottomPosition, filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))' }"
|
|
||||||
@click="closeChat"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" 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>
|
|
||||||
Close Chat
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Loading indicator while iframe loads -->
|
<!-- Loading indicator while iframe loads -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading">
|
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading">
|
||||||
@ -37,12 +25,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- AIUI iframe -->
|
<!-- AIUI iframe — on mobile, leave room for close bar + tab bar at bottom -->
|
||||||
<iframe
|
<iframe
|
||||||
v-if="aiuiUrl"
|
v-if="aiuiUrl"
|
||||||
ref="aiuiFrame"
|
ref="aiuiFrame"
|
||||||
:src="aiuiUrl"
|
:src="aiuiUrl"
|
||||||
class="chat-iframe"
|
class="chat-iframe chat-iframe-mobile"
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
allow="microphone"
|
allow="microphone"
|
||||||
style="background: transparent"
|
style="background: transparent"
|
||||||
@ -65,6 +53,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile close bar: sits above the tab bar, below the AIUI content -->
|
||||||
|
<div class="md:hidden mobile-back-btn flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
class="w-full glass-button px-6 py-2.5 rounded-lg font-medium flex items-center justify-center gap-2 text-sm"
|
||||||
|
style="background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);"
|
||||||
|
@click="closeChat"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" 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>
|
||||||
|
Close Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -72,9 +74,7 @@
|
|||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ContextBroker } from '@/services/contextBroker'
|
import { ContextBroker } from '@/services/contextBroker'
|
||||||
import { useMobileBackButton } from '@/composables/useMobileBackButton'
|
|
||||||
|
|
||||||
const { bottomPosition } = useMobileBackButton()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||||
const aiuiConnected = ref(false)
|
const aiuiConnected = ref(false)
|
||||||
|
|||||||
@ -160,12 +160,10 @@ import { ref, computed, watch } from 'vue'
|
|||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useCloudStore } from '../stores/cloud'
|
import { useCloudStore } from '../stores/cloud'
|
||||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
|
||||||
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
||||||
import FileGrid from '../components/cloud/FileGrid.vue'
|
import FileGrid from '../components/cloud/FileGrid.vue'
|
||||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||||
|
|
||||||
useMobileBackButton()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|||||||
@ -286,12 +286,11 @@
|
|||||||
|
|
||||||
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
|
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
|
||||||
<nav
|
<nav
|
||||||
v-show="!chatFullscreen"
|
|
||||||
ref="mobileTabBar"
|
ref="mobileTabBar"
|
||||||
data-mobile-tab-bar
|
data-mobile-tab-bar
|
||||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
||||||
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
||||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);"
|
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: env(safe-area-inset-bottom, 0px);"
|
||||||
>
|
>
|
||||||
<div class="flex justify-around items-center px-2 py-3 relative">
|
<div class="flex justify-around items-center px-2 py-3 relative">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export default defineConfig({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
|
||||||
globIgnores: [
|
globIgnores: [
|
||||||
'**/*-backup-*.mp4',
|
'**/*-backup-*.mp4',
|
||||||
@ -140,6 +141,11 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/app/filebrowser': {
|
||||||
|
target: 'http://192.168.1.228',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@ -150,7 +150,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
# Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build)
|
# Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build)
|
||||||
echo "$(timestamp) Deploying frontend..."
|
echo "$(timestamp) Deploying frontend..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -rf $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
||||||
|
|
||||||
# Build and deploy AIUI
|
# Build and deploy AIUI
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user