Refactor Indeehub integration and enhance deployment documentation
- Updated Indeehub references throughout the codebase, changing the name from "IndeedHub" to "Indeehub" for consistency. - Implemented a virtual app structure for Indeehub, allowing it to open an external URL without requiring a container. - Enhanced deployment scripts and documentation to clarify SSH access and password management for Indeehub. - Improved error handling and retry logic in various components to ensure better user experience during onboarding and app interactions. - Updated CSS for visual enhancements and added new buttons for improved navigation in the AppLauncherOverlay.
This commit is contained in:
parent
2c15311ab6
commit
7a05e11834
@ -29,7 +29,8 @@ Do not leave deployment or testing to the user. The agent has SSH access to perf
|
||||
|
||||
### Backend: build on server via rsync (never on macOS)
|
||||
- **Always** deploy backend by: (1) rsync `core/` to `archipelago@192.168.1.228:~/archy/core/`, then (2) SSH and run `cargo build --release` on the server, then copy binary to `/usr/local/bin/` and restart `archipelago.service`.
|
||||
- Use `sshpass -p "archipelago"` for non-interactive rsync/SSH. **Action these builds** when making backend changes; do not build the Rust binary on macOS and copy it (causes Exec format error on Linux).
|
||||
- Use `sshpass -p 'EwPDR8q45l0Upx@'` for non-interactive rsync/SSH. The password is stored in `scripts/deploy-config.sh` (gitignored) and sourced by the deploy script automatically.
|
||||
- **Do not** build the Rust binary on macOS and copy it (causes Exec format error on Linux).
|
||||
|
||||
### Standard Deployment Command
|
||||
|
||||
@ -61,13 +62,16 @@ Deploys to 192.168.1.228 first (builds there), then copies binary and web-ui to
|
||||
- **Web Server**: Nginx
|
||||
- **Backend**: Systemd service (`archipelago.service`) running as root
|
||||
|
||||
## SSH Key Management
|
||||
## SSH Access
|
||||
|
||||
The deployment scripts require SSH key authentication. If you encounter `Permission denied` errors:
|
||||
**Current credentials**: `archipelago@192.168.1.228` with password `EwPDR8q45l0Upx@`
|
||||
|
||||
1. Ensure SSH key is loaded: `ssh-add -l`
|
||||
2. Add key if needed: `ssh-add ~/.ssh/id_ed25519`
|
||||
3. Enter passphrase when prompted
|
||||
The deploy script sources `scripts/deploy-config.sh` (gitignored) which sets `ARCHIPELAGO_PASSWORD`. For manual SSH/rsync commands, use:
|
||||
```bash
|
||||
sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228
|
||||
```
|
||||
|
||||
If `sshpass` hangs, SSH may be rate-limited from too many connections. Wait 10-15 seconds and retry.
|
||||
|
||||
## Development Paths
|
||||
|
||||
@ -136,7 +140,7 @@ Common containers:
|
||||
### Debug a Fresh ISO Install
|
||||
|
||||
1. **Flash** the ISO to a test machine (e.g. 192.168.1.198)
|
||||
2. **SSH** after first boot (same user/password as dev: `archipelago`/`archipelago`):
|
||||
2. **SSH** after first boot (default ISO password is `archipelago`, dev server uses `EwPDR8q45l0Upx@`):
|
||||
```bash
|
||||
ssh-keygen -R 192.168.1.198 # if host key changed after reflash
|
||||
sshpass -p "archipelago" ssh -o StrictHostKeyChecking=no archipelago@192.168.1.198
|
||||
|
||||
44
apps/indeedhub/README.md
Normal file
44
apps/indeedhub/README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# IndeedHub (Indeehub Prototype)
|
||||
|
||||
Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
|
||||
|
||||
## Building the Image
|
||||
|
||||
The app image is built from the **Indeehub Prototype** project. The prototype lives at `../../Indeedhub Prototype` (relative to the archy repo).
|
||||
|
||||
### Option 1: Build from prototype directory
|
||||
|
||||
```bash
|
||||
cd "/path/to/Indeedhub Prototype"
|
||||
podman build -t localhost/indeedhub:latest .
|
||||
```
|
||||
|
||||
### Option 2: Use the build script
|
||||
|
||||
```bash
|
||||
# From archy repo root
|
||||
./apps/indeedhub/build-from-prototype.sh
|
||||
```
|
||||
|
||||
### Option 3: Full deploy (build + run on server)
|
||||
|
||||
```bash
|
||||
cd "/path/to/Indeedhub Prototype"
|
||||
./deploy-to-archipelago.sh
|
||||
```
|
||||
|
||||
## Installing from My Apps
|
||||
|
||||
1. **Build the image** using one of the options above (the image must exist before install)
|
||||
2. Go to **Dashboard → App Store** (Marketplace)
|
||||
3. Find **Indeehub Prototype** and click **Install**
|
||||
4. The app will appear in **My Apps** once the container is running
|
||||
|
||||
## Port
|
||||
|
||||
- Web UI: 7777
|
||||
|
||||
## Container
|
||||
|
||||
- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry)
|
||||
- Port: 7777
|
||||
30
apps/indeedhub/build-from-prototype.sh
Executable file
30
apps/indeedhub/build-from-prototype.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Build Indeehub image from the Indeehub Prototype project
|
||||
# Usage: ./build-from-prototype.sh [path-to-prototype]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_PROTOTYPE="$SCRIPT_DIR/../../Indeedhub Prototype"
|
||||
PROTOTYPE_DIR="${1:-$DEFAULT_PROTOTYPE}"
|
||||
IMAGE_TAG="localhost/indeedhub:latest"
|
||||
|
||||
if [ ! -d "$PROTOTYPE_DIR" ]; then
|
||||
echo "❌ Indeehub Prototype not found at: $PROTOTYPE_DIR"
|
||||
echo " Set path: $0 /path/to/Indeedhub\ Prototype"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine container runtime
|
||||
RUNTIME="podman"
|
||||
if ! command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME="docker"
|
||||
fi
|
||||
|
||||
echo "🔨 Building Indeehub from $PROTOTYPE_DIR"
|
||||
cd "$PROTOTYPE_DIR"
|
||||
$RUNTIME build -t "$IMAGE_TAG" .
|
||||
|
||||
echo "✅ Built $IMAGE_TAG"
|
||||
echo ""
|
||||
echo "You can now install Indeehub from the App Store in Archipelago."
|
||||
@ -1,6 +1,6 @@
|
||||
app:
|
||||
id: indeedhub
|
||||
name: IndeedHub
|
||||
name: Indeehub
|
||||
version: 0.1.0
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
|
||||
category: media
|
||||
@ -59,7 +59,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: IndeedHub Team
|
||||
author: Indeehub Team
|
||||
website: https://indeedhub.com
|
||||
source: https://github.com/indeedhub/indeedhub
|
||||
license: MIT
|
||||
|
||||
@ -618,6 +618,11 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
|
||||
// Virtual app: Indeehub (no container, opens external URL)
|
||||
if package_id == "indeedhub" {
|
||||
return Ok(serde_json::json!({ "success": true }));
|
||||
}
|
||||
|
||||
// Multi-container apps: create full stack
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
@ -637,17 +642,34 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Container {} already exists. Stop and remove it first.", package_id));
|
||||
}
|
||||
|
||||
// Pull the image (with verification in the future)
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
let pull_output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "pull", docker_image])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to pull image")?;
|
||||
// Pull the image (skip for local images - must be built locally first)
|
||||
let is_local_image = docker_image.starts_with("localhost/");
|
||||
if !is_local_image {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
let pull_output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "pull", docker_image])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to pull image")?;
|
||||
|
||||
if !pull_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&pull_output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||
if !pull_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&pull_output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||
}
|
||||
} else {
|
||||
// Verify local image exists
|
||||
let images_output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "images", "-q", docker_image])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check local image")?;
|
||||
if String::from_utf8_lossy(&images_output.stdout).trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Local image {} not found. Run ./deploy-to-archipelago.sh from the Indeehub Prototype project on your Mac—it builds the image on this server and starts the app.",
|
||||
docker_image
|
||||
));
|
||||
}
|
||||
debug!("Using local image: {}", docker_image);
|
||||
}
|
||||
|
||||
// Create and start container with security constraints
|
||||
@ -1708,6 +1730,13 @@ fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"indeedhub" => (
|
||||
vec!["7777:7777".to_string()],
|
||||
vec![],
|
||||
vec!["NGINX_HOST=0.0.0.0".to_string(), "NGINX_PORT=7777".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
_ => (vec![], vec![], vec![], None, None), // No default config, user must configure manually
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,6 +202,65 @@ impl DockerPackageScanner {
|
||||
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
|
||||
}
|
||||
|
||||
// Virtual app: Indeehub (opens external URL, no container required)
|
||||
if !packages.contains_key("indeedhub") {
|
||||
let metadata = get_app_metadata("indeedhub");
|
||||
let lan_address = Some("https://archipelago.indeehub.studio".to_string());
|
||||
let virtual_pkg = PackageDataEntry {
|
||||
state: PackageState::Running,
|
||||
static_files: StaticFiles {
|
||||
license: "MIT".to_string(),
|
||||
instructions: metadata.description.clone(),
|
||||
icon: metadata.icon.clone(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: "indeedhub".to_string(),
|
||||
title: metadata.title.clone(),
|
||||
version: "0.1.0".to_string(),
|
||||
description: Description {
|
||||
short: metadata.description.clone(),
|
||||
long: metadata.description.clone(),
|
||||
},
|
||||
release_notes: "Virtual app (opens archipelago.indeehub.studio)".to_string(),
|
||||
license: "MIT".to_string(),
|
||||
wrapper_repo: metadata.repo.clone(),
|
||||
upstream_repo: metadata.repo.clone(),
|
||||
support_site: metadata.repo.clone(),
|
||||
marketing_site: metadata.repo.clone(),
|
||||
donation_url: None,
|
||||
author: Some("Indeehub Team".to_string()),
|
||||
website: lan_address.clone(),
|
||||
interfaces: Some(Interfaces {
|
||||
main: Some(MainInterface {
|
||||
ui: Some("true".to_string()),
|
||||
tor_config: None,
|
||||
lan_config: None,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
installed: Some(InstalledPackageDataEntry {
|
||||
current_dependents: HashMap::new(),
|
||||
current_dependencies: HashMap::new(),
|
||||
last_backup: None,
|
||||
interface_addresses: {
|
||||
let mut addresses = HashMap::new();
|
||||
addresses.insert(
|
||||
"main".to_string(),
|
||||
InterfaceAddress {
|
||||
tor_address: String::new(),
|
||||
lan_address: lan_address,
|
||||
},
|
||||
);
|
||||
addresses
|
||||
},
|
||||
status: ServiceStatus::Running,
|
||||
}),
|
||||
install_progress: None,
|
||||
};
|
||||
packages.insert("indeedhub".to_string(), virtual_pkg);
|
||||
info!("Virtual app: Indeehub (archipelago.indeehub.studio)");
|
||||
}
|
||||
|
||||
Ok(packages)
|
||||
}
|
||||
}
|
||||
@ -360,9 +419,9 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/tailscale/tailscale".to_string(),
|
||||
},
|
||||
"indeedhub" => AppMetadata {
|
||||
title: "IndeedHub".to_string(),
|
||||
title: "Indeehub".to_string(),
|
||||
description: "Decentralized media streaming platform".to_string(),
|
||||
icon: "/assets/img/app-icons/indeedhub.png".to_string(),
|
||||
icon: "https://indeehub.studio/favicon.ico".to_string(),
|
||||
repo: "https://github.com/indeedhub/indeedhub".to_string(),
|
||||
},
|
||||
_ => AppMetadata {
|
||||
|
||||
@ -24,44 +24,66 @@ class RPCClient {
|
||||
|
||||
async call<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 30000 } = options
|
||||
const maxRetries = 3
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Important for session cookies
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ method, params }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
try {
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Important for session cookies
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ method, params }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: RPCResponse<T> = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message || 'RPC Error')
|
||||
}
|
||||
|
||||
return data.result as T
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timeout')
|
||||
if (!response.ok) {
|
||||
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
const isRetryable = response.status === 502 || response.status === 503
|
||||
if (isRetryable && attempt < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
throw err
|
||||
}
|
||||
throw error
|
||||
|
||||
const data: RPCResponse<T> = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message || 'RPC Error')
|
||||
}
|
||||
|
||||
return data.result as T
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
const timeoutErr = new Error('Request timeout')
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
throw timeoutErr
|
||||
}
|
||||
const msg = error.message
|
||||
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
|
||||
if (isRetryable && attempt < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('Unknown error occurred')
|
||||
}
|
||||
throw new Error('Unknown error occurred')
|
||||
}
|
||||
|
||||
throw new Error('Request failed after retries')
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
|
||||
@ -352,7 +352,7 @@ function getWebSocketClient(): WebSocketClient {
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__archipelago_ws_client = wsClientInstance
|
||||
}
|
||||
console.log('[WebSocket] Created new client instance')
|
||||
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
|
||||
}
|
||||
|
||||
return wsClientInstance
|
||||
|
||||
@ -39,6 +39,17 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Open in new tab"
|
||||
title="Open in new tab"
|
||||
@click="openInNewTab"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
ref="closeBtnRef"
|
||||
type="button"
|
||||
@ -86,6 +97,12 @@ function refreshIframe() {
|
||||
iframeRefreshKey.value++
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
if (store.url) {
|
||||
window.open(store.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
function onIframeLoad() {
|
||||
injectScrollbarHideIfSameOrigin()
|
||||
isRefreshing.value = false
|
||||
|
||||
@ -6,10 +6,18 @@ let audioContext: AudioContext | null = null
|
||||
let introAudio: HTMLAudioElement | null = null
|
||||
let introGain: GainNode | null = null
|
||||
|
||||
/** Get AudioContext - only returns existing. Create via resumeAudioContext() after user gesture. */
|
||||
function getContext(): AudioContext | null {
|
||||
return audioContext
|
||||
}
|
||||
|
||||
/** Create AudioContext if needed (call only from user gesture - click/tap/key) */
|
||||
function ensureContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const Ctx = window.AudioContext || (window as any).webkitAudioContext
|
||||
if (!Ctx) return null
|
||||
audioContext = new Ctx()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
@ -44,21 +52,21 @@ export function playLoopStart() {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Resume audio context (call on first user interaction to unlock autoplay) */
|
||||
/** Resume audio context - MUST be called from user gesture (click/tap/key). Creates context if needed. */
|
||||
export function resumeAudioContext() {
|
||||
const ctx = getContext()
|
||||
const ctx = ensureContext()
|
||||
if (ctx?.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Start intro loop - Cosmic Updrift (free royalty) */
|
||||
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
|
||||
export function startSynthwave() {
|
||||
const ctxOrNull = getContext()
|
||||
if (!ctxOrNull) return
|
||||
|
||||
try {
|
||||
if (ctxOrNull.state === 'suspended') ctxOrNull.resume()
|
||||
if (ctxOrNull.state === 'suspended') ctxOrNull.resume().catch(() => {})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,20 +1,30 @@
|
||||
/**
|
||||
* Onboarding state - prefers backend, falls back to localStorage for mock/offline.
|
||||
* Hardened: retries on 502/503, never blocks completion.
|
||||
*/
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export async function isOnboardingComplete(): Promise<boolean> {
|
||||
try {
|
||||
return await rpcClient.isOnboardingComplete()
|
||||
} catch {
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T | null> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
const isRetryable = /502|503|timeout|fetch|network/i.test(msg)
|
||||
if (!isRetryable || i === maxRetries - 1) return null
|
||||
await new Promise((r) => setTimeout(r, 800 * (i + 1)))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function isOnboardingComplete(): Promise<boolean> {
|
||||
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
|
||||
if (result !== null) return result
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<void> {
|
||||
try {
|
||||
await rpcClient.completeOnboarding()
|
||||
} finally {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
}
|
||||
await callWithRetry(() => rpcClient.completeOnboarding(), 3)
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
}
|
||||
|
||||
@ -134,8 +134,18 @@ router.beforeEach(async (to, _from, next) => {
|
||||
|
||||
// Allow all public routes (login, onboarding) without auth check
|
||||
if (isPublic) {
|
||||
// If already authenticated and trying to access login, redirect to home
|
||||
// If authenticated and visiting /login, validate session first
|
||||
if (to.path === '/login' && store.isAuthenticated) {
|
||||
if (store.needsSessionValidation()) {
|
||||
const valid = await store.checkSession()
|
||||
if (valid) {
|
||||
next({ name: 'home' })
|
||||
return
|
||||
}
|
||||
// Session invalid, allow login page
|
||||
next()
|
||||
return
|
||||
}
|
||||
next({ name: 'home' })
|
||||
return
|
||||
}
|
||||
@ -143,30 +153,35 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Protected routes require authentication
|
||||
// Check session if not already authenticated
|
||||
// Protected routes: validate session if stale auth from localStorage
|
||||
if (store.needsSessionValidation()) {
|
||||
const valid = await store.checkSession()
|
||||
if (!valid) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Not authenticated at all
|
||||
if (!store.isAuthenticated) {
|
||||
const hasSession = await store.checkSession()
|
||||
if (hasSession) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// No valid session - redirect to login
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// User is already authenticated (from localStorage on page load)
|
||||
// Make sure WebSocket is connected
|
||||
// Validated and authenticated - ensure WebSocket is connected
|
||||
if (!store.isConnected && !store.isReconnecting) {
|
||||
console.log('[Router] User authenticated but WebSocket not connected, connecting...')
|
||||
store.connectWebSocket().catch((err) => {
|
||||
console.warn('[Router] WebSocket connection failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticated user accessing protected route
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let isWsSubscribed = false
|
||||
let sessionValidated = false
|
||||
|
||||
// Computed
|
||||
const serverInfo = computed(() => data.value?.['server-info'])
|
||||
@ -33,13 +34,16 @@ export const useAppStore = defineStore('app', () => {
|
||||
try {
|
||||
await rpcClient.login(password)
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
|
||||
// Connect WebSocket after successful login
|
||||
await connectWebSocket()
|
||||
|
||||
// Initialize data
|
||||
// Initialize data structure immediately so dashboard can render
|
||||
await initializeData()
|
||||
|
||||
// Connect WebSocket in background - don't block login flow
|
||||
connectWebSocket().catch((err) => {
|
||||
console.warn('[Store] WebSocket connection failed after login, will retry:', err)
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
throw err
|
||||
@ -55,10 +59,10 @@ export const useAppStore = defineStore('app', () => {
|
||||
console.error('Logout error:', err)
|
||||
} finally {
|
||||
isAuthenticated.value = false
|
||||
sessionValidated = false
|
||||
localStorage.removeItem('neode-auth')
|
||||
data.value = null
|
||||
isWsSubscribed = false
|
||||
// Disconnect WebSocket on logout (this prevents reconnection)
|
||||
wsClient.disconnect()
|
||||
isConnected.value = false
|
||||
isReconnecting.value = false
|
||||
@ -182,48 +186,42 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check session validity on app load
|
||||
// Check session validity on app load or stale auth
|
||||
async function checkSession(): Promise<boolean> {
|
||||
console.log('[Store] Checking session...')
|
||||
|
||||
if (!localStorage.getItem('neode-auth')) {
|
||||
console.log('[Store] No auth token found')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to make an authenticated request to verify session
|
||||
console.log('[Store] Validating session with backend...')
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
|
||||
isAuthenticated.value = true
|
||||
console.log('[Store] Session valid, reconnecting WebSocket...')
|
||||
sessionValidated = true
|
||||
|
||||
// Initialize data structure first
|
||||
await initializeData()
|
||||
|
||||
// Connect WebSocket - don't wait for it, let it reconnect in background
|
||||
// This ensures the page loads quickly even if WebSocket is slow
|
||||
connectWebSocket().catch((err) => {
|
||||
console.warn('[Store] WebSocket reconnection failed, will retry automatically:', err)
|
||||
// The WebSocket client will handle retries automatically
|
||||
console.warn('[Store] WebSocket reconnection failed, will retry:', err)
|
||||
isReconnecting.value = true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[Store] Session check failed:', err)
|
||||
// Session invalid, clear auth
|
||||
localStorage.removeItem('neode-auth')
|
||||
isAuthenticated.value = false
|
||||
sessionValidated = false
|
||||
isWsSubscribed = false
|
||||
isConnected.value = false
|
||||
isReconnecting.value = false
|
||||
// Disconnect WebSocket if session is invalid
|
||||
wsClient.disconnect()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function needsSessionValidation(): boolean {
|
||||
return isAuthenticated.value && !sessionValidated
|
||||
}
|
||||
|
||||
// Package actions
|
||||
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
|
||||
return rpcClient.installPackage(id, marketplaceUrl, version)
|
||||
@ -293,6 +291,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
login,
|
||||
logout,
|
||||
checkSession,
|
||||
needsSessionValidation,
|
||||
connectWebSocket,
|
||||
installPackage,
|
||||
uninstallPackage,
|
||||
|
||||
@ -16,14 +16,45 @@ function mustOpenInNewTab(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrite to same-origin proxy so iframe can embed (nginx strips X-Frame-Options) */
|
||||
/** Port → proxy path for apps (nginx strips X-Frame-Options) */
|
||||
const PORT_TO_PROXY: Record<string, string> = {
|
||||
'81': '/app/nginx-proxy-manager/',
|
||||
'3000': '/app/grafana/',
|
||||
'3001': '/app/uptime-kuma/',
|
||||
'8080': '/app/endurain/',
|
||||
'8081': '/app/lnd/',
|
||||
'8082': '/app/vaultwarden/',
|
||||
'8083': '/app/filebrowser/',
|
||||
'8085': '/app/nextcloud/',
|
||||
'8096': '/app/jellyfin/',
|
||||
'8123': '/app/homeassistant/',
|
||||
'8240': '/app/tailscale/',
|
||||
'8334': '/app/bitcoin-ui/',
|
||||
'8888': '/app/searxng/',
|
||||
'9000': '/app/portainer/',
|
||||
'9001': '/app/penpot/',
|
||||
'9980': '/app/onlyoffice/',
|
||||
'11434': '/app/ollama/',
|
||||
'2283': '/app/immich/',
|
||||
'23000': '/app/btcpay/',
|
||||
'2342': '/app/photoprism/',
|
||||
'4080': '/app/mempool/',
|
||||
'50002': '/app/electrs/',
|
||||
'8175': '/app/fedimint/',
|
||||
}
|
||||
|
||||
/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */
|
||||
function toEmbeddableUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const origin = window.location.origin
|
||||
// Only Vaultwarden and Penpot support subpath proxy; Nextcloud/Immich open in new tab
|
||||
if (u.port === '8082') return `${origin}/app/vaultwarden/`
|
||||
if (u.port === '9001') return `${origin}/app/penpot/`
|
||||
const proxyPath = PORT_TO_PROXY[u.port]
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
|
||||
// Use proxy when: (a) mixed content, or (b) vaultwarden/penpot always (subpath required)
|
||||
if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) {
|
||||
return `${origin}${proxyPath}`
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@ -37,12 +68,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||
const embeddableUrl = toEmbeddableUrl(payload.url)
|
||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
||||
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||
window.open(embeddableUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||
url.value = toEmbeddableUrl(payload.url)
|
||||
url.value = embeddableUrl
|
||||
title.value = payload.title
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
@ -556,7 +556,7 @@ html::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Subtle black glitch overlay */
|
||||
/* Subtle black glitch overlay - delay to avoid flash of partial clip-path on first paint (black crescent bug) */
|
||||
body::before {
|
||||
background-image: url('/assets/img/bg.jpg');
|
||||
background-size: auto 100vh;
|
||||
@ -566,6 +566,8 @@ body::before {
|
||||
filter: brightness(0.4) contrast(1.2);
|
||||
will-change: transform, clip-path, opacity;
|
||||
animation: bg-glitch-shift-repeat 5s steps(10, end) infinite;
|
||||
animation-delay: 1.5s;
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
/* Second subtle black layer */
|
||||
@ -578,6 +580,8 @@ html::before {
|
||||
filter: brightness(0.3) contrast(1.3);
|
||||
will-change: transform, clip-path, opacity;
|
||||
animation: bg-glitch-shift-2-repeat 5s steps(9, end) infinite;
|
||||
animation-delay: 1.6s;
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
/* Subtle scanline sweep */
|
||||
@ -588,6 +592,8 @@ body::after {
|
||||
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.15) 100%);
|
||||
will-change: transform, opacity;
|
||||
animation: bg-glitch-scan-repeat 5s ease-out infinite;
|
||||
animation-delay: 1.5s;
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
/* Dashboard: full viewport width, no letterboxing */
|
||||
|
||||
@ -515,15 +515,15 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Decentralized media streaming platform',
|
||||
icon: '/assets/img/app-icons/indeedhub.png'
|
||||
icon: 'https://indeehub.studio/favicon.ico'
|
||||
},
|
||||
manifest: {
|
||||
id: 'indeedhub',
|
||||
title: 'IndeedHub',
|
||||
title: 'Indeehub',
|
||||
version: '0.1.0',
|
||||
description: {
|
||||
short: 'Decentralized media streaming platform',
|
||||
long: 'IndeedHub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web.'
|
||||
long: 'Indeehub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web.'
|
||||
},
|
||||
'release-notes': 'Initial release with Netflix-inspired interface',
|
||||
license: 'MIT',
|
||||
@ -539,8 +539,8 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'indeedhub.onion',
|
||||
'lan-address': 'http://localhost:7777'
|
||||
'tor-address': '',
|
||||
'lan-address': 'https://archipelago.indeehub.studio'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
|
||||
@ -643,8 +643,8 @@ function launchApp() {
|
||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||
},
|
||||
'indeedhub': {
|
||||
dev: 'http://localhost:7777',
|
||||
prod: 'http://localhost:7777' // Containerized indeehub prototype
|
||||
dev: 'https://archipelago.indeehub.studio',
|
||||
prod: 'https://archipelago.indeehub.studio'
|
||||
},
|
||||
// Dummy apps - replace with real URLs when packaged
|
||||
'bitcoin': {
|
||||
|
||||
@ -245,8 +245,8 @@ function launchApp(id: string) {
|
||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||
},
|
||||
'indeedhub': {
|
||||
dev: 'http://localhost:7777',
|
||||
prod: 'http://localhost:7777' // Containerized indeehub prototype
|
||||
dev: 'https://archipelago.indeehub.studio',
|
||||
prod: 'https://archipelago.indeehub.studio'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -701,8 +701,14 @@ function getIconPath(iconName: string): string[] {
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await store.logout()
|
||||
router.push('/login')
|
||||
try {
|
||||
await store.logout()
|
||||
} catch {
|
||||
/* proceed to login regardless */
|
||||
}
|
||||
router.push('/login').catch(() => {
|
||||
window.location.href = '/login'
|
||||
})
|
||||
}
|
||||
|
||||
// Track previous route for transition logic
|
||||
|
||||
@ -136,7 +136,7 @@ import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
@ -155,17 +155,25 @@ const isSetupMode = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
|
||||
startSynthwave()
|
||||
} else {
|
||||
sessionStorage.removeItem('archipelago_from_splash')
|
||||
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
|
||||
if (fromSplash) sessionStorage.removeItem('archipelago_from_splash')
|
||||
const unlock = () => {
|
||||
if (!fromSplash) {
|
||||
resumeAudioContext()
|
||||
startSynthwave()
|
||||
}
|
||||
document.removeEventListener('click', unlock)
|
||||
document.removeEventListener('touchstart', unlock)
|
||||
document.removeEventListener('keydown', unlock)
|
||||
}
|
||||
document.addEventListener('click', unlock, { once: true })
|
||||
document.addEventListener('touchstart', unlock, { once: true })
|
||||
document.addEventListener('keydown', unlock, { once: true })
|
||||
if (isSetupMode.value) {
|
||||
try {
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {} })
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
||||
isSetup.value = Boolean(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to check setup status:', err)
|
||||
} catch {
|
||||
isSetup.value = false
|
||||
}
|
||||
} else {
|
||||
@ -207,10 +215,17 @@ async function handleSetup() {
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await store.login(password.value)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
router.replace({ name: 'home' })
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
error.value = err instanceof Error ? err.message : 'Setup failed. Please try again.'
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = 'Server is starting up. Please try again in a moment.'
|
||||
} else {
|
||||
error.value = msg || 'Setup failed. Please try again.'
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
@ -237,10 +252,17 @@ async function handleLogin() {
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
router.replace({ name: 'home' })
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
error.value = err instanceof Error ? err.message : 'Login failed. Please check your password.'
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = 'Server is starting up. Please try again in a moment.'
|
||||
} else {
|
||||
error.value = msg || 'Login failed. Please check your password.'
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@ -851,6 +851,17 @@ function getCuratedAppList() {
|
||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
|
||||
manifestUrl: null,
|
||||
repoUrl: 'https://github.com/fedimint/fedimint'
|
||||
},
|
||||
{
|
||||
id: 'indeedhub',
|
||||
title: 'Indeehub',
|
||||
version: '0.1.0',
|
||||
description: 'Bitcoin documentary streaming platform. Stream God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.',
|
||||
icon: 'https://indeehub.studio/favicon.ico',
|
||||
author: 'Indeehub Team',
|
||||
dockerImage: 'localhost/indeedhub:latest',
|
||||
manifestUrl: null,
|
||||
repoUrl: 'https://github.com/indeedhub/indeedhub'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -139,11 +139,11 @@ async function downloadBackup() {
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
router.push('/onboarding/verify')
|
||||
router.push('/onboarding/verify').catch(() => {})
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
router.push('/onboarding/verify')
|
||||
router.push('/onboarding/verify').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -101,30 +101,33 @@ async function generateDid() {
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||
generatedDid.value = did
|
||||
localStorage.setItem('neode_did', did)
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to load node identity'
|
||||
// Fallback: show placeholder if backend unavailable (e.g. mock mode)
|
||||
if (!generatedDid.value) {
|
||||
generatedDid.value = 'did:key:z6Mk... (connect to server)'
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||
generatedDid.value = did
|
||||
localStorage.setItem('neode_did', did)
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
|
||||
break
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Server unavailable. Retrying...'
|
||||
if (attempt < 2) {
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
||||
} else {
|
||||
generatedDid.value = 'did:key:z6Mk... (connect to server)'
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
isGenerating.value = false
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
if (generatedDid.value && !generatedDid.value.includes('...')) {
|
||||
router.push('/onboarding/backup')
|
||||
router.push('/onboarding/backup').catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
router.push('/onboarding/backup')
|
||||
router.push('/onboarding/backup').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
|
||||
function goToOptions() {
|
||||
router.push('/onboarding/path')
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -99,8 +99,12 @@ function selectOption(option: string) {
|
||||
|
||||
async function proceed() {
|
||||
if (selected.value) {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
try {
|
||||
await completeOnboarding()
|
||||
} catch {
|
||||
/* localStorage fallback ensures onboarding is marked complete */
|
||||
}
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -139,15 +139,12 @@ function toggleOption(option: string) {
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Save selected options to localStorage
|
||||
localStorage.setItem('neode_selected_paths', JSON.stringify(selectedOptions.value))
|
||||
// Don't mark onboarding complete yet - continue to DID creation
|
||||
router.push('/onboarding/did')
|
||||
router.push('/onboarding/did').catch(() => {})
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Skip to DID creation
|
||||
router.push('/onboarding/did')
|
||||
router.push('/onboarding/did').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -113,13 +113,21 @@ function generateMockSignature(): string {
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
try {
|
||||
await completeOnboarding()
|
||||
} catch {
|
||||
/* localStorage fallback ensures we can proceed */
|
||||
}
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
|
||||
async function skipForNow() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
try {
|
||||
await completeOnboarding()
|
||||
} catch {
|
||||
/* localStorage fallback ensures we can proceed */
|
||||
}
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -322,16 +322,18 @@ onMounted(() => {
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
document.body.classList.add('video-background-active')
|
||||
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
|
||||
startSynthwave()
|
||||
}
|
||||
const unlock = () => {
|
||||
resumeAudioContext()
|
||||
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
|
||||
startSynthwave()
|
||||
}
|
||||
document.removeEventListener('click', unlock)
|
||||
document.removeEventListener('touchstart', unlock)
|
||||
document.removeEventListener('keydown', unlock)
|
||||
}
|
||||
document.addEventListener('click', unlock, { once: true })
|
||||
document.addEventListener('touchstart', unlock, { once: true })
|
||||
document.addEventListener('keydown', unlock, { once: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -20,11 +20,20 @@ const router = useRouter()
|
||||
onMounted(async () => {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.replace('/login')
|
||||
router.replace('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const seenOnboarding = await isOnboardingComplete()
|
||||
router.replace(seenOnboarding ? '/login' : '/onboarding/intro')
|
||||
let seenOnboarding = false
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
isOnboardingComplete(),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000)),
|
||||
])
|
||||
seenOnboarding = result
|
||||
} catch {
|
||||
seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
}
|
||||
router.replace(seenOnboarding ? '/login' : '/onboarding/intro').catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -507,7 +507,10 @@ if [ "$LIVE" = true ]; then
|
||||
# Recreate Fedimint with FM_API_URL for Guardian UI (fixes "Api URL must be configured")
|
||||
echo " Fixing Fedimint API URL..."
|
||||
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
TIMEOUT_CMD=""
|
||||
command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 90"
|
||||
command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout 90"
|
||||
($TIMEOUT_CMD sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^fedimint\$'); do
|
||||
@ -530,7 +533,7 @@ if [ "$LIVE" = true ]; then
|
||||
docker.io/fedimint/fedimintd:v0.10.0
|
||||
break
|
||||
done
|
||||
" 2>&1 | sed 's/^/ /' || true
|
||||
" 2>&1 | sed 's/^/ /') || echo " (Fedimint fix timed out or skipped - run manually if needed)"
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployed to live system!"
|
||||
|
||||
178
scripts/nginx-https-app-proxies.conf
Normal file
178
scripts/nginx-https-app-proxies.conf
Normal file
@ -0,0 +1,178 @@
|
||||
# App proxies for HTTPS - avoids mixed content when embedding apps from HTTPS page
|
||||
# Complete list for all apps that may be launched from the UI
|
||||
location /app/grafana/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/uptime-kuma/ {
|
||||
proxy_pass http://127.0.0.1:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/searxng/ {
|
||||
proxy_pass http://127.0.0.1:8888/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/portainer/ {
|
||||
proxy_pass http://127.0.0.1:9000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/filebrowser/ {
|
||||
proxy_pass http://127.0.0.1:8083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/endurain/ {
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/onlyoffice/ {
|
||||
proxy_pass http://127.0.0.1:9980/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/jellyfin/ {
|
||||
proxy_pass http://127.0.0.1:8096/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/photoprism/ {
|
||||
proxy_pass http://127.0.0.1:2342/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/mempool/ {
|
||||
proxy_pass http://127.0.0.1:4080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/fedimint/ {
|
||||
proxy_pass http://127.0.0.1:8175/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/tailscale/ {
|
||||
proxy_pass http://127.0.0.1:8240/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/ollama/ {
|
||||
proxy_pass http://127.0.0.1:11434/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/bitcoin-ui/ {
|
||||
proxy_pass http://127.0.0.1:8334/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/electrs/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/nginx-proxy-manager/ {
|
||||
proxy_pass http://127.0.0.1:81/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
@ -55,6 +55,21 @@ if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && [ -f "$PWA_SNIPPET" ]; t
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install app proxies snippet (mempool, fedimint, lnd, etc.) - fixes apps not opening over HTTPS (mixed content)
|
||||
APPS_SNIPPET="$NGINX_SNIPPETS/archipelago-https-app-proxies.conf"
|
||||
if [ -f "$SCRIPT_DIR/nginx-https-app-proxies.conf" ]; then
|
||||
cp "$SCRIPT_DIR/nginx-https-app-proxies.conf" "$APPS_SNIPPET"
|
||||
echo " HTTPS app proxies snippet installed at $APPS_SNIPPET"
|
||||
# Add include to HTTPS block if missing
|
||||
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && ! grep -q "archipelago-https-app-proxies" "$NGINX_CFG" 2>/dev/null; then
|
||||
echo " Adding app proxies include to HTTPS block..."
|
||||
sed -i '/listen 443 ssl/,/^}$/{
|
||||
/location \/ws {/i\
|
||||
include snippets/archipelago-https-app-proxies.conf;
|
||||
}' "$NGINX_CFG" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if HTTPS is already configured
|
||||
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
|
||||
echo "HTTPS already configured in nginx."
|
||||
@ -102,6 +117,121 @@ server {
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /app/nextcloud/ {
|
||||
proxy_pass http://127.0.0.1:8085/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/vaultwarden/ {
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/immich/ {
|
||||
proxy_pass http://127.0.0.1:2283/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/penpot/ {
|
||||
proxy_pass http://127.0.0.1:9001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/btcpay/ {
|
||||
proxy_pass http://127.0.0.1:23000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/homeassistant/ {
|
||||
proxy_pass http://127.0.0.1:8123/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
location /app/mempool/ {
|
||||
proxy_pass http://127.0.0.1:4080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/fedimint/ {
|
||||
proxy_pass http://127.0.0.1:8175/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/bitcoin-ui/ {
|
||||
proxy_pass http://127.0.0.1:8334/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
66
scripts/trust-archipelago-cert.sh
Executable file
66
scripts/trust-archipelago-cert.sh
Executable file
@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Trust the Archipelago server's self-signed certificate on macOS.
|
||||
# Run this to eliminate "Not secure" when accessing https://192.168.1.228
|
||||
#
|
||||
# Usage: ./scripts/trust-archipelago-cert.sh [host]
|
||||
# Default host: 192.168.1.228
|
||||
#
|
||||
# Requires: SSH access to archipelago@host (uses deploy-config.sh password)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
HOST="${1:-192.168.1.228}"
|
||||
CERT_FILE="/tmp/archipelago-${HOST}.crt"
|
||||
KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Try to fetch cert from server via SSH (most reliable)
|
||||
if [ -f "$SCRIPT_DIR/deploy-config.sh" ]; then
|
||||
. "$SCRIPT_DIR/deploy-config.sh"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no"
|
||||
if command -v sshpass >/dev/null 2>&1; then
|
||||
echo "Fetching certificate from server..."
|
||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS archipelago@${HOST} \
|
||||
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: fetch via openssl (can hang on some systems)
|
||||
if [ ! -s "$CERT_FILE" ]; then
|
||||
echo "Fetching certificate via TLS..."
|
||||
(echo "Q"; sleep 1) | openssl s_client -connect "${HOST}:443" -servername "${HOST}" 2>/dev/null | \
|
||||
openssl x509 -outform PEM > "$CERT_FILE"
|
||||
fi
|
||||
|
||||
if [ ! -s "$CERT_FILE" ]; then
|
||||
echo "Failed to fetch certificate. Ensure deploy-config.sh exists and SSH works, or the server is reachable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Adding to your login keychain..."
|
||||
|
||||
# Remove old cert if present (by common name)
|
||||
security delete-certificate -c "archipelago.local" "$KEYCHAIN" 2>/dev/null || true
|
||||
|
||||
# Add to user keychain with trust (no sudo needed)
|
||||
if security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
|
||||
echo " Certificate trusted successfully."
|
||||
elif security add-trusted-cert -d -r trustAsRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
|
||||
echo " Certificate trusted successfully."
|
||||
else
|
||||
# Fallback: add cert and open Keychain Access for manual trust
|
||||
cp "$CERT_FILE" "$HOME/Desktop/archipelago-${HOST}.crt"
|
||||
echo ""
|
||||
echo " Could not auto-trust. Certificate saved to Desktop."
|
||||
echo " Double-click archipelago-${HOST}.crt to add it, then in Keychain Access"
|
||||
echo " find it, double-click, expand Trust → set to 'Always Trust'."
|
||||
CERT_FILE="" # Don't delete, we copied to Desktop
|
||||
fi
|
||||
|
||||
rm -f "$CERT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "✅ Done. Restart your browser fully (quit Chrome/Safari) and visit https://${HOST}"
|
||||
Loading…
x
Reference in New Issue
Block a user