Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46dd853614 | ||
|
|
977b8d06b8 | ||
|
|
f08f34ca10 | ||
|
|
aaa3477d5e | ||
|
|
90bedc2a25 | ||
|
|
a66e4bac6d | ||
|
|
af2dfd0bd6 | ||
|
|
68c25534d4 | ||
|
|
45c9def94a | ||
|
|
299f7d8f39 | ||
|
|
bb231e82b4 | ||
|
|
e2309cc2ac | ||
|
|
a48499daeb | ||
|
|
6df9afe684 | ||
|
|
9d21dd5111 | ||
|
|
ddbfaf1d00 | ||
|
|
2e5f67a59a | ||
|
|
51d1c89137 | ||
|
|
785fb3d2be | ||
|
|
2b3a18f189 | ||
|
|
3028685e6b | ||
|
|
1a3170f1c3 | ||
|
|
547f674ac8 | ||
|
|
92d221bbf1 | ||
|
|
e9cfc234cd | ||
|
|
06186ab30c | ||
|
|
e5c7210663 | ||
|
|
500472944c | ||
|
|
a687df9bd9 | ||
|
|
0aa9463b36 | ||
|
|
5cb53ade66 | ||
|
|
47dea8cd55 | ||
|
|
72d7fa07ff | ||
|
|
6dcdada371 | ||
|
|
454c4bb25c | ||
|
|
66fd121748 | ||
|
|
fa91faa67c | ||
|
|
e9c3311eff | ||
|
|
338ae9a6de | ||
|
|
8f397b03f9 | ||
|
|
48d5fd0045 | ||
|
|
a44cbe478a | ||
|
|
519b4b209a | ||
|
|
2c82b498f0 | ||
|
|
efd1ae41de | ||
|
|
0c591a997e | ||
|
|
91e1059f56 | ||
|
|
3d986b81a0 | ||
|
|
2efed23afd | ||
|
|
df403c5a69 | ||
|
|
a34634e00f | ||
|
|
c13ee58edf | ||
|
|
6e5a99ef71 | ||
|
|
749c351e5e | ||
|
|
dc897064cd | ||
|
|
178ba85c5c | ||
|
|
0f5ea9ae15 | ||
|
|
5d4d40e9e8 | ||
|
|
8caa4c7d07 | ||
|
|
4983ba4e20 | ||
|
|
994795e4d2 | ||
|
|
4226b5ec0a | ||
|
|
8f6312fe7b | ||
|
|
bdf283fcff |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 16
|
||||
versionName = "0.4.12"
|
||||
versionCode = 18
|
||||
versionName = "0.4.14"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -111,6 +111,12 @@ dependencies {
|
||||
// OkHttp for WebSocket (remote input)
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning
|
||||
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||
implementation("androidx.camera:camera-view:1.3.4")
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".ArchipelagoApp"
|
||||
@@ -19,6 +22,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Archipelago.Splash"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
||||
@@ -26,6 +30,14 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Pairing deep link from the web UI's Companion popup:
|
||||
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="archipelago" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
// Pairing deep link (archipelago://pair?...) from the launch intent or a
|
||||
// later one (launchMode=singleTask). Consumed by AppNavHost.
|
||||
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingPairUri.value = intent?.dataString
|
||||
setContent {
|
||||
ArchipelagoTheme {
|
||||
AppNavHost()
|
||||
val pairUri by pendingPairUri.collectAsState()
|
||||
AppNavHost(
|
||||
pairUri = pairUri,
|
||||
onPairUriConsumed = { pendingPairUri.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
pendingPairUri.value = intent.dataString
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,39 @@ class ServerPreferences(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a server, or update the entry with the same connection identity
|
||||
* (address/port/scheme) — used by QR pairing so re-scanning a node never
|
||||
* duplicates it. A blank incoming password/name keeps the stored value
|
||||
* (a real node's QR never carries the password). Returns the merged entry.
|
||||
*/
|
||||
suspend fun upsertServer(server: ServerEntry): ServerEntry {
|
||||
var merged = server
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
|
||||
it.address == server.address &&
|
||||
it.port == server.port &&
|
||||
it.useHttps == server.useHttps
|
||||
}
|
||||
if (existing != null) {
|
||||
merged = server.copy(
|
||||
password = server.password.ifBlank { existing.password },
|
||||
name = server.name.ifBlank { existing.name },
|
||||
)
|
||||
}
|
||||
val filtered = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
}.toSet()
|
||||
prefs[savedServersKey] = filtered + merged.serialize()
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
suspend fun removeSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Result of parsing a pairing QR / deep link.
|
||||
*
|
||||
* UnsupportedVersion means the payload is structurally a pairing URI but its
|
||||
* major version is newer than this app understands — the UI should tell the
|
||||
* user to update the app rather than call the code invalid.
|
||||
*/
|
||||
sealed class PairResult {
|
||||
data class Success(val server: ServerEntry) : PairResult()
|
||||
object UnsupportedVersion : PairResult()
|
||||
object Invalid : PairResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser for the companion pairing QR / OS deep link. Contract:
|
||||
* docs/companion-pairing-qr.md (repo root).
|
||||
*
|
||||
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
|
||||
*
|
||||
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
|
||||
* for the public demo); trailing slashes are normalized away.
|
||||
* - `pw` is only ever present for the public demo.
|
||||
* - Unknown extra query params are tolerated (forward compat under v=1 —
|
||||
* `name` is already honored if present).
|
||||
*/
|
||||
object ServerQrParser {
|
||||
private const val SUPPORTED_MAJOR = 1
|
||||
|
||||
fun parse(raw: String): PairResult {
|
||||
val uri = try {
|
||||
Uri.parse(raw.trim())
|
||||
} catch (_: Exception) {
|
||||
return PairResult.Invalid
|
||||
}
|
||||
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid
|
||||
if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid
|
||||
|
||||
val major = uri.getQueryParameter("v")
|
||||
?.trim()
|
||||
?.takeWhile { it.isDigit() }
|
||||
?.toIntOrNull()
|
||||
?: return PairResult.Invalid
|
||||
if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion
|
||||
|
||||
val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/')
|
||||
if (serverUrl.isNullOrBlank()) return PairResult.Invalid
|
||||
val server = Uri.parse(serverUrl)
|
||||
val scheme = server.scheme?.lowercase()
|
||||
if (scheme != "http" && scheme != "https") return PairResult.Invalid
|
||||
val host = server.host
|
||||
if (host.isNullOrBlank()) return PairResult.Invalid
|
||||
|
||||
return PairResult.Success(
|
||||
ServerEntry(
|
||||
address = host,
|
||||
useHttps = scheme == "https",
|
||||
port = if (server.port != -1) server.port.toString() else "",
|
||||
password = uri.getQueryParameter("pw") ?: "",
|
||||
name = uri.getQueryParameter("name") ?: "",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -44,6 +48,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
@@ -75,6 +80,7 @@ fun NESMenu(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)? = null,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
@@ -88,7 +94,7 @@ fun NESMenu(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +109,7 @@ private fun MenuPanel(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)?,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
@@ -235,7 +242,30 @@ private fun MenuPanel(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
if (onScanQr != null) {
|
||||
// Add server by scanning the node's pairing QR
|
||||
Box(
|
||||
Modifier
|
||||
.size(ROW_H)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onScanQr() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = stringResource(R.string.add_server_qr),
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Full-screen camera overlay that scans the node pairing QR
|
||||
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
|
||||
* Handles the camera permission itself; foreign/invalid codes show a hint
|
||||
* and scanning continues.
|
||||
*/
|
||||
@Composable
|
||||
fun QrScannerOverlay(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (ServerEntry) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||
var handled by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
handled = false
|
||||
hintRes = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
// Foreign-code hint fades after a moment so scanning feels live again.
|
||||
LaunchedEffect(hintRes) {
|
||||
if (hintRes != null) {
|
||||
delay(2500)
|
||||
hintRes = null
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
if (hasPermission) {
|
||||
CameraQrPreview(
|
||||
onDecoded = { text ->
|
||||
if (!handled) {
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result.server)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
// Aim frame
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(260.dp)
|
||||
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Top bar: title + close
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_node_qr),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom hints
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
hintRes?.let { res ->
|
||||
Text(
|
||||
text = stringResource(res),
|
||||
color = BitcoinOrange,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasPermission) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_qr_hint),
|
||||
color = TextMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
var provider: ProcessCameraProvider? = null
|
||||
|
||||
providerFuture.addListener({
|
||||
val p = providerFuture.get()
|
||||
provider = p
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// CameraX's analysis default is 640x480 — too few pixels per module
|
||||
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
||||
// doubles the pixel density at negligible analysis cost.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1280, 720))
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(
|
||||
analysisExecutor,
|
||||
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
||||
)
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
} catch (_: Exception) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
}
|
||||
}, mainExecutor)
|
||||
|
||||
onDispose {
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
// Screen-displayed QRs come with moiré, glare, and soft focus at
|
||||
// close range — the exhaustive search is worth the milliseconds.
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
// Copy into a rowStride-wide array; the last row of the plane buffer
|
||||
// may be short of the full stride, so the tail stays zero-padded.
|
||||
val data = ByteArray(plane.rowStride * image.height)
|
||||
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data, plane.rowStride, image.height,
|
||||
0, 0, image.width, image.height,
|
||||
false,
|
||||
)
|
||||
val result = try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
} catch (_: NotFoundException) {
|
||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||
}
|
||||
onDecoded(result.text)
|
||||
} catch (_: NotFoundException) {
|
||||
// No QR in this frame — keep scanning.
|
||||
} catch (_: Exception) {
|
||||
// Malformed frame; skip it.
|
||||
} finally {
|
||||
reader.reset()
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
package com.archipelago.app.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
@@ -24,7 +30,10 @@ object Routes {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost() {
|
||||
fun AppNavHost(
|
||||
pairUri: String? = null,
|
||||
onPairUriConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val navController = rememberNavController()
|
||||
@@ -33,8 +42,41 @@ fun AppNavHost() {
|
||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
|
||||
// Pairing entry from a deep link that carried no password — prefills the
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
|
||||
if (introSeen == null) return
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
// below has set the nav graph; pairUri stays pending until consumed here.
|
||||
LaunchedEffect(pairUri) {
|
||||
val raw = pairUri ?: return@LaunchedEffect
|
||||
onPairUriConsumed()
|
||||
when (val result = ServerQrParser.parse(raw)) {
|
||||
is PairResult.Success -> {
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
prefs.markIntroSeen()
|
||||
val merged = prefs.upsertServer(result.server)
|
||||
if (merged.password.isNotBlank()) {
|
||||
// Demo flow: password came with the link — connect in one step.
|
||||
prefs.setActiveServer(merged)
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
pairPrefill = merged
|
||||
navController.navigate(Routes.SERVER_CONNECT) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startDestination = when {
|
||||
introSeen == false -> Routes.INTRO
|
||||
activeServer != null -> Routes.WEB_VIEW
|
||||
@@ -65,6 +107,7 @@ fun AppNavHost() {
|
||||
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
|
||||
}
|
||||
},
|
||||
initialServer = pairPrefill,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +124,7 @@ fun AppNavHost() {
|
||||
} else {
|
||||
WebViewScreen(
|
||||
serverUrl = server.toUrl(),
|
||||
serverPassword = server.password,
|
||||
onDisconnect = {
|
||||
scope.launch {
|
||||
prefs.clearActiveServer()
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.archipelago.app.ui.components.NESController
|
||||
import com.archipelago.app.ui.components.NESKeyboard
|
||||
import com.archipelago.app.ui.components.NESMenu
|
||||
import com.archipelago.app.ui.components.NESPortraitController
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.components.Trackpad
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
@@ -63,6 +64,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||
|
||||
@@ -216,6 +218,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onAddServer = { server ->
|
||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||
},
|
||||
onScanQr = { showQrScanner = true },
|
||||
onEditServer = { original, updated ->
|
||||
scope.launch {
|
||||
prefs.updateSavedServer(original, updated)
|
||||
@@ -247,5 +250,19 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
},
|
||||
onBackToWebView = { showModal = false; onBack() },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
|
||||
// open behind the scanner so the new entry appears as soon as it closes.
|
||||
QrScannerOverlay(
|
||||
visible = showQrScanner,
|
||||
onDismiss = { showQrScanner = false },
|
||||
onServerScanned = { server ->
|
||||
showQrScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(server)
|
||||
if (activeServer == null) prefs.setActiveServer(merged)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -72,6 +73,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
@@ -93,6 +95,9 @@ import javax.net.ssl.X509TrustManager
|
||||
fun ServerConnectScreen(
|
||||
onConnected: (String) -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
// Prefill from a pairing deep link (archipelago://pair) that carried no
|
||||
// password — opens the manual form on the password prompt for that server.
|
||||
initialServer: ServerEntry? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
@@ -109,6 +114,9 @@ fun ServerConnectScreen(
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
// The saved server currently being edited, or null when adding/connecting.
|
||||
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
|
||||
var manualMode by remember { mutableStateOf(false) }
|
||||
var showScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
|
||||
@@ -173,6 +181,37 @@ fun ServerConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun prefill(server: ServerEntry) {
|
||||
name = server.name
|
||||
address = server.address
|
||||
port = server.port
|
||||
password = server.password
|
||||
useHttps = server.useHttps
|
||||
}
|
||||
|
||||
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
|
||||
// (payload carried a password — the demo flow) or land on the password
|
||||
// prompt with everything else filled in (real nodes never embed one).
|
||||
fun onQrScanned(scanned: ServerEntry) {
|
||||
showScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(scanned)
|
||||
prefill(merged)
|
||||
if (merged.password.isNotBlank()) {
|
||||
connect(merged)
|
||||
} else {
|
||||
manualMode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(initialServer) {
|
||||
if (initialServer != null) {
|
||||
prefill(prefs.upsertServer(initialServer))
|
||||
manualMode = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -208,7 +247,10 @@ fun ServerConnectScreen(
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
// Center the content vertically — the landing (logo + two buttons) is
|
||||
// short and looks stranded at the top otherwise. Taller content (the
|
||||
// manual form, saved servers) still scrolls from the top as normal.
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
// Circular badge logo
|
||||
Image(
|
||||
@@ -226,8 +268,10 @@ fun ServerConnectScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
val showForm = manualMode || editingServer != null
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.server_address_hint),
|
||||
text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -235,8 +279,25 @@ fun ServerConnectScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (!showForm) {
|
||||
// Landing: scan the pairing QR, or fall back to manual entry
|
||||
GlassButton(
|
||||
text = stringResource(R.string.scan_node_qr),
|
||||
onClick = { showScanner = true },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.enter_manually),
|
||||
onClick = {
|
||||
errorMessage = null
|
||||
manualMode = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Glass card with form
|
||||
Box(
|
||||
if (showForm) Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
@@ -458,16 +519,30 @@ fun ServerConnectScreen(
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Connect button — glass style
|
||||
GlassButton(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
} else if (manualMode) {
|
||||
// Back to the Scan/Manual landing + Connect
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GlassButton(
|
||||
text = stringResource(R.string.back),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
manualMode = false
|
||||
clearForm()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
)
|
||||
GlassButton(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.weight(2f).height(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
@@ -499,6 +574,12 @@ fun ServerConnectScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QrScannerOverlay(
|
||||
visible = showScanner,
|
||||
onDismiss = { showScanner = false },
|
||||
onServerScanned = { onQrScanned(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,10 @@ fun WebViewScreen(
|
||||
serverUrl: String,
|
||||
onDisconnect: () -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
// Stored password for this server (from QR pairing or manual entry). When
|
||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||
// demo flow from docs/companion-pairing-qr.md.
|
||||
serverPassword: String = "",
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadProgress by remember { mutableIntStateOf(0) }
|
||||
@@ -310,6 +314,12 @@ fun WebViewScreen(
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
|
||||
// Auto-login with the stored password (QR pairing /
|
||||
// saved server) — only on our own server's pages.
|
||||
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
|
||||
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
@@ -700,3 +710,39 @@ private fun InAppBrowser(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JS that fills the web UI login form (Login.vue's #login-password) with the
|
||||
* stored password and submits it once the form is interactive — the one-step
|
||||
* pairing flow. No-ops when the login step never appears (already
|
||||
* authenticated, first-boot setup, TOTP). At most two attempts per page load,
|
||||
* then it stops for good so a wrong stored password can't spam the node.
|
||||
*/
|
||||
private fun buildAutoLoginScript(password: String): String {
|
||||
val quoted = org.json.JSONObject.quote(password)
|
||||
return """
|
||||
(function () {
|
||||
if (window.__archyAutoLogin) return;
|
||||
window.__archyAutoLogin = true;
|
||||
var pw = $quoted;
|
||||
var attempts = 0;
|
||||
var started = Date.now();
|
||||
var timer = setInterval(function () {
|
||||
if (Date.now() - started > 45000) { clearInterval(timer); return; }
|
||||
var el = document.getElementById('login-password');
|
||||
if (!el) {
|
||||
// Field gone after we submitted = success or step change - stop.
|
||||
if (attempts > 0) clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
if (el.disabled) return; // form waits on serverReady
|
||||
if (attempts >= 2) { clearInterval(timer); return; }
|
||||
attempts++;
|
||||
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(el, pw);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
}, 1500);
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
<string name="server_name_label">Server Name (optional)</string>
|
||||
<string name="server_name_placeholder">My Archipelago</string>
|
||||
<string name="edit_server">Edit</string>
|
||||
<string name="scan_node_qr">Scan Node\'s QR</string>
|
||||
<string name="enter_manually">Enter Manually</string>
|
||||
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
|
||||
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
|
||||
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
|
||||
<string name="grant_camera_access">Grant Camera Access</string>
|
||||
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
|
||||
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
|
||||
<string name="add_server_qr">Add server by QR</string>
|
||||
<string name="edit_server_title">Edit Server</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.101-alpha (2026-07-15)
|
||||
|
||||
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
|
||||
- Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step.
|
||||
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
|
||||
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
|
||||
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
|
||||
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.
|
||||
- The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — "Replay Intro" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.
|
||||
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
|
||||
- The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
|
||||
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
|
||||
|
||||
## v1.7.100-alpha (2026-07-14)
|
||||
|
||||
- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.
|
||||
|
||||
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.100-alpha"
|
||||
version = "1.7.101-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.100-alpha"
|
||||
version = "1.7.101-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -194,6 +194,39 @@ impl ApiHandler {
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
|
||||
/// browser download. The archive is passphrase-encrypted at rest; the
|
||||
/// session gate at the route controls who can fetch it.
|
||||
async fn handle_backup_download(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let id = path.strip_prefix("/api/blob/backup/").unwrap_or("");
|
||||
// Backup ids are UUIDs — reject anything that could traverse paths.
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
|
||||
));
|
||||
}
|
||||
let file = self.config.data_dir.join("backups").join(format!("{id}.bak"));
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"archipelago-backup-{id}.bak\""),
|
||||
)
|
||||
.header("Content-Length", bytes.len())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))),
|
||||
Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"backup not found"}"#),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
@@ -434,6 +467,16 @@ impl ApiHandler {
|
||||
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
|
||||
}
|
||||
|
||||
// Backup archive download — session-gated. Lives under /api/blob/
|
||||
// so the existing nginx `location /api/blob` prefix proxies it on
|
||||
// every fleet node without a config change.
|
||||
(Method::GET, p) if p.starts_with("/api/blob/backup/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_backup_download(p).await
|
||||
}
|
||||
|
||||
// Blob upload — local/session use only. Session-authenticated so
|
||||
// only the node owner can push attachments into the blob store.
|
||||
(Method::POST, "/api/blob") => {
|
||||
|
||||
@@ -55,14 +55,20 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
};
|
||||
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let bal = client
|
||||
.balance()
|
||||
.await
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let spendable = sat("spendable_sat");
|
||||
let pending = sat("pending_in_round_sat")
|
||||
+ sat("pending_board_sat")
|
||||
+ sat("pending_lightning_send_sat")
|
||||
+ sat("claimable_lightning_receive_sat")
|
||||
+ bal.get("pending_exit_sat").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
+ bal
|
||||
.get("pending_exit_sat")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let onchain = client
|
||||
.onchain_balance()
|
||||
.await
|
||||
|
||||
@@ -804,7 +804,7 @@ async fn http_launch_url_reachable(url: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn port_from_url(url: &str) -> Option<u16> {
|
||||
pub(in crate::api::rpc) fn port_from_url(url: &str) -> Option<u16> {
|
||||
let after_colon = url.rsplit_once(':')?.1;
|
||||
let port = after_colon
|
||||
.chars()
|
||||
|
||||
@@ -110,6 +110,14 @@ impl RpcHandler {
|
||||
)
|
||||
.await;
|
||||
handler.clear_install_progress(&package_id_spawn).await;
|
||||
// Auto-expose the app over Tor (best-effort, detached) —
|
||||
// every installed app gets its .onion without a manual
|
||||
// "Add Service" step.
|
||||
let tor_handler = Arc::clone(&handler);
|
||||
let tor_app = package_id_spawn.clone();
|
||||
tokio::spawn(async move {
|
||||
tor_handler.auto_add_tor_service(&tor_app).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||
|
||||
@@ -246,6 +246,11 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
pub(super) const DEP_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
/// 36 × 5s = 3 minutes of bounded waiting.
|
||||
pub(super) const DEP_WAIT_MAX_ATTEMPTS: u32 = 36;
|
||||
/// Separate, much larger budget while a dependency is still INSTALLING
|
||||
/// (image pulling, container not created yet): 360 × 5s = 30 minutes. A
|
||||
/// fresh-node Bitcoin pull routinely takes >3 minutes, and rejecting LND
|
||||
/// mid-pull was the confirmed "first install fails, second works" failure.
|
||||
pub(super) const DEP_INSTALLING_WAIT_MAX_ATTEMPTS: u32 = 360;
|
||||
|
||||
/// Marker error: the install was rejected by the dependency gate BEFORE any
|
||||
/// resource (container, image, data dir) was created for the package. The
|
||||
@@ -317,8 +322,12 @@ pub(super) struct DepProbe {
|
||||
/// Which dependency services are currently Running.
|
||||
pub running: RunningDeps,
|
||||
/// Container/package names that EXIST in any state — installed, but
|
||||
/// possibly not running yet (`podman ps -a` ∪ package-state entries).
|
||||
/// possibly not running yet (`podman ps -a`).
|
||||
pub existing: Vec<String>,
|
||||
/// Package ids currently mid-install (state Installing/Updating) —
|
||||
/// no container exists yet, but one is on the way, so the gate must
|
||||
/// wait for the install to finish instead of failing fast.
|
||||
pub installing: Vec<String>,
|
||||
}
|
||||
|
||||
/// All container names known to podman in any state (`podman ps -a`).
|
||||
@@ -366,8 +375,13 @@ where
|
||||
LF: std::future::Future<Output = ()>,
|
||||
{
|
||||
let mut waited_attempts = 0u32;
|
||||
let mut installing_attempts = 0u32;
|
||||
loop {
|
||||
let DepProbe { running, existing } = probe().await?;
|
||||
let DepProbe {
|
||||
running,
|
||||
existing,
|
||||
installing,
|
||||
} = probe().await?;
|
||||
let missing = missing_install_deps(package_id, &running);
|
||||
if missing.is_empty() {
|
||||
// Keep behavior in lockstep with the canonical gate (covers any
|
||||
@@ -377,11 +391,12 @@ where
|
||||
}
|
||||
|
||||
// Fail fast if any missing dependency has no installed container
|
||||
// under any name variant — waiting cannot satisfy it.
|
||||
// under any name variant AND no install in flight — waiting cannot
|
||||
// satisfy it.
|
||||
let some_dep_not_installed = missing.iter().any(|dep| {
|
||||
!dep.containers
|
||||
.iter()
|
||||
.any(|c| existing.iter().any(|e| e == c))
|
||||
!dep.containers.iter().any(|c| {
|
||||
existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c)
|
||||
})
|
||||
});
|
||||
if some_dep_not_installed {
|
||||
let msg = match check_install_deps(package_id, &running) {
|
||||
@@ -391,8 +406,38 @@ where
|
||||
return Err(anyhow::Error::new(DependencyGateError(msg)));
|
||||
}
|
||||
|
||||
// A dependency still mid-install (no container yet) gets its own,
|
||||
// much larger budget: a fresh-node image pull can take far longer
|
||||
// than the started-but-not-running window.
|
||||
let some_dep_installing = missing.iter().any(|dep| {
|
||||
dep.containers
|
||||
.iter()
|
||||
.any(|c| installing.iter().any(|i| i == c))
|
||||
});
|
||||
|
||||
let labels = join_dep_labels(&missing);
|
||||
if some_dep_installing {
|
||||
if installing_attempts >= DEP_INSTALLING_WAIT_MAX_ATTEMPTS {
|
||||
return Err(anyhow::Error::new(DependencyGateError(format!(
|
||||
"{labels} is still installing after {} seconds. Wait for it \
|
||||
to finish, then install {package_id} again.",
|
||||
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
|
||||
))));
|
||||
}
|
||||
installing_attempts += 1;
|
||||
if installing_attempts == 1 {
|
||||
info!(
|
||||
"Install {package_id}: dependency {labels} is still installing — \
|
||||
waiting up to {}s for it to finish",
|
||||
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
|
||||
);
|
||||
}
|
||||
on_waiting(format!("Waiting for {labels} to finish installing…")).await;
|
||||
tokio::time::sleep(interval).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if waited_attempts >= max_attempts {
|
||||
let labels = join_dep_labels(&missing);
|
||||
return Err(anyhow::Error::new(DependencyGateError(format!(
|
||||
"{labels} is installed but did not reach the running state within \
|
||||
{} seconds. Start {labels}, then install {package_id} again.",
|
||||
@@ -401,7 +446,6 @@ where
|
||||
}
|
||||
waited_attempts += 1;
|
||||
|
||||
let labels = join_dep_labels(&missing);
|
||||
if waited_attempts == 1 {
|
||||
info!(
|
||||
"Install {package_id}: dependency {labels} installed but not running yet — \
|
||||
@@ -874,9 +918,19 @@ mod tests {
|
||||
}
|
||||
|
||||
fn probe(has_bitcoin: bool, has_electrumx: bool, existing: &[&str]) -> DepProbe {
|
||||
probe_with_installing(has_bitcoin, has_electrumx, existing, &[])
|
||||
}
|
||||
|
||||
fn probe_with_installing(
|
||||
has_bitcoin: bool,
|
||||
has_electrumx: bool,
|
||||
existing: &[&str],
|
||||
installing: &[&str],
|
||||
) -> DepProbe {
|
||||
DepProbe {
|
||||
running: deps(has_bitcoin, has_electrumx),
|
||||
existing: existing.iter().map(|s| s.to_string()).collect(),
|
||||
installing: installing.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,6 +1019,71 @@ mod tests {
|
||||
assert!(labels.iter().all(|l| l == "Waiting for Bitcoin to start…"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waits_while_dependency_is_still_installing_then_passes() {
|
||||
// Bitcoin has NO container yet (image still pulling) but its
|
||||
// package state is Installing. Old behavior failed fast here —
|
||||
// the confirmed fresh-node LND "first install fails" bug. The
|
||||
// gate must wait past the normal started-but-not-running budget
|
||||
// (max_attempts=1 below) while the install is in flight.
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
let result = wait_for_install_deps(
|
||||
"lnd",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
Ok(match n {
|
||||
// pulling: no container, package Installing
|
||||
0 | 1 => probe_with_installing(false, false, &[], &["bitcoin-knots"]),
|
||||
// container created, starting
|
||||
2 => probe(false, false, &["bitcoin-knots"]),
|
||||
// running
|
||||
_ => probe(true, false, &["bitcoin-knots"]),
|
||||
})
|
||||
}
|
||||
},
|
||||
sink,
|
||||
1,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 4);
|
||||
let labels = labels.lock().unwrap();
|
||||
assert_eq!(
|
||||
labels.as_slice(),
|
||||
[
|
||||
"Waiting for Bitcoin to finish installing…",
|
||||
"Waiting for Bitcoin to finish installing…",
|
||||
"Waiting for Bitcoin to start…",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_fast_when_dependency_neither_installed_nor_installing() {
|
||||
// installing list has unrelated packages only — no reason to wait.
|
||||
let calls = AtomicU32::new(0);
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"lnd",
|
||||
|| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(probe_with_installing(false, false, &[], &["grafana"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(labels.lock().unwrap().is_empty());
|
||||
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn times_out_when_installed_dependency_never_runs() {
|
||||
let (labels, sink) = label_sink();
|
||||
|
||||
@@ -984,6 +984,24 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Package ids currently mid-install/update per the state manager — used
|
||||
/// by the dependency gate so an app whose dependency is still pulling its
|
||||
/// image (no container yet) waits instead of failing fast.
|
||||
async fn packages_currently_installing(&self) -> Vec<String> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
data.package_data
|
||||
.iter()
|
||||
.filter(|(_, entry)| {
|
||||
matches!(
|
||||
entry.state,
|
||||
crate::data_model::PackageState::Installing
|
||||
| crate::data_model::PackageState::Updating
|
||||
)
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn running_deps_for_install(&self, package_id: &str) -> Result<RunningDeps> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let cached = detect_running_deps_from_package_data(&data.package_data);
|
||||
@@ -1006,6 +1024,7 @@ impl RpcHandler {
|
||||
Ok(DepProbe {
|
||||
running: self.running_deps_for_install(package_id).await?,
|
||||
existing: detect_existing_containers().await,
|
||||
installing: self.packages_currently_installing().await,
|
||||
})
|
||||
},
|
||||
|msg| async move { self.set_install_message(package_id, &msg).await },
|
||||
@@ -1089,46 +1108,97 @@ impl RpcHandler {
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
// 5-minute per-URL budget. A full install tries each configured mirror
|
||||
// once, so a two-registry setup fails visibly in roughly 10 minutes
|
||||
// instead of staying in Installing for up to an hour.
|
||||
const PULL_URL_TIMEOUT_SECS: u64 = 300;
|
||||
let pull_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(PULL_URL_TIMEOUT_SECS),
|
||||
async {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
// Stall-aware budget instead of a hard wall clock. The old fixed
|
||||
// 300s cap killed slow-but-progressing pulls (LND on fresh-node
|
||||
// WiFi routinely needs longer), which surfaced as "first install
|
||||
// fails, second succeeds" once the layer cache was warm. A pull is
|
||||
// only killed when NOTHING is observably happening — no stderr
|
||||
// output and no byte growth in podman's staging dir — for
|
||||
// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling.
|
||||
// Dead registries still fail fast: no bytes ever land, so the
|
||||
// stall window (3 min) is the effective bound.
|
||||
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
|
||||
const PULL_URL_MAX_SECS: u64 = 1800;
|
||||
const PULL_POLL_INTERVAL_SECS: u64 = 5;
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||
.await;
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
// Seconds-since-start of the last stderr line, updated by the
|
||||
// reader task. u64::MAX sentinel = no line seen yet (treated as
|
||||
// activity at t=0 so the stall window starts at spawn).
|
||||
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
let line_clock = std::sync::Arc::clone(&last_line_at);
|
||||
let started_reader = started;
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
line_clock.store(
|
||||
started_reader.elapsed().as_secs(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
child.wait().await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
match pull_result {
|
||||
Ok(Ok(status)) => Ok(status.success()),
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Image pull process error on {}: {}", url, e);
|
||||
Ok(false)
|
||||
let mut last_staged_bytes = dir_size_bytes(user_tmp);
|
||||
let mut last_staged_change = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Ok(status.success()),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("Image pull process error on {}: {}", url, e);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
|
||||
|
||||
if started.elapsed() > std::time::Duration::from_secs(PULL_URL_MAX_SECS) {
|
||||
tracing::warn!(
|
||||
"Image pull timed out after {}s: {}",
|
||||
PULL_URL_TIMEOUT_SECS,
|
||||
"Image pull exceeded absolute {}s ceiling: {}",
|
||||
PULL_URL_MAX_SECS,
|
||||
url
|
||||
);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
Ok(false)
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Activity signal 1: stderr output from podman.
|
||||
let line_age = started
|
||||
.elapsed()
|
||||
.as_secs()
|
||||
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
|
||||
// Activity signal 2: podman stages layer downloads in TMPDIR
|
||||
// (user_tmp) — any size change there means bytes are moving.
|
||||
let staged = dir_size_bytes(user_tmp);
|
||||
if staged != last_staged_bytes {
|
||||
last_staged_bytes = staged;
|
||||
last_staged_change = std::time::Instant::now();
|
||||
}
|
||||
|
||||
let stalled = line_age > PULL_STALL_TIMEOUT_SECS
|
||||
&& last_staged_change.elapsed()
|
||||
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS);
|
||||
if stalled {
|
||||
tracing::warn!(
|
||||
"Image pull stalled ({}s with no output and no staged bytes): {}",
|
||||
PULL_STALL_TIMEOUT_SECS,
|
||||
url
|
||||
);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2690,6 +2760,31 @@ async fn persist_install_version_selection(app_id: &str, version: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
|
||||
/// "bytes are moving" signal for pull stall detection — exact size doesn't
|
||||
/// matter, only whether it CHANGES between polls. Errors count as 0.
|
||||
fn dir_size_bytes(path: &str) -> u64 {
|
||||
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
|
||||
if depth > 8 {
|
||||
return 0;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut total = 0u64;
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
total = total.saturating_add(walk(&entry.path(), depth + 1));
|
||||
} else {
|
||||
total = total.saturating_add(meta.len());
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
walk(std::path::Path::new(path), 0)
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
||||
}
|
||||
|
||||
@@ -621,8 +621,33 @@ async fn install_stack_via_orchestrator(
|
||||
))
|
||||
.await;
|
||||
|
||||
// Phase: PullingImage — each member's orchestrator.install covers its
|
||||
// whole pipeline (pull + create + start + health wait), and on a fresh
|
||||
// node the pull dominates wall-clock. The X-of-N counter below is what
|
||||
// the UI interpolates across the PullingImage band (20→70%); without
|
||||
// these updates an orchestrator-installed stack sat at "Preparing… 5%"
|
||||
// for the entire multi-gigabyte pull (indeedhub: 7 images).
|
||||
let total = app_ids.len() as u64;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::PullingImage)
|
||||
.await;
|
||||
|
||||
let mut installed = 0usize;
|
||||
for app_id in app_ids {
|
||||
for (idx, app_id) in app_ids.iter().enumerate() {
|
||||
handler
|
||||
.set_install_progress(stack_name, idx as u64, total)
|
||||
.await;
|
||||
// Message after progress: set_install_progress resets the label,
|
||||
// set_install_message preserves the phase + counters just written.
|
||||
let component = app_id
|
||||
.strip_prefix(&format!("{stack_name}-"))
|
||||
.unwrap_or(app_id);
|
||||
handler
|
||||
.set_install_message(
|
||||
stack_name,
|
||||
&format!("Installing {} ({} of {})…", component, idx + 1, total),
|
||||
)
|
||||
.await;
|
||||
match orchestrator.install(app_id).await {
|
||||
Ok(container_name) => {
|
||||
installed += 1;
|
||||
@@ -672,6 +697,20 @@ async fn install_stack_via_orchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
// Truthful end-of-install signal, mirroring the legacy stack installers:
|
||||
// the real readiness gate is the scanner's next sweep, this just settles
|
||||
// the bar at 95→100→done instead of leaving it mid-band.
|
||||
handler
|
||||
.set_install_progress(stack_name, total, total)
|
||||
.await;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::PostInstall)
|
||||
.await;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::Done)
|
||||
.await;
|
||||
handler.clear_install_progress(stack_name).await;
|
||||
|
||||
install_log(&format!("INSTALL ORCH OK: {} stack", stack_name)).await;
|
||||
Ok(Some(serde_json::json!({
|
||||
"success": true,
|
||||
|
||||
@@ -53,6 +53,7 @@ impl RpcHandler {
|
||||
// hit a hostname-mismatch warning on top of the usual self-signed one
|
||||
// the moment a node is renamed.
|
||||
if hostname_updated {
|
||||
sync_hostname_side_effects(&hostname).await;
|
||||
if let Err(e) = regenerate_tls_cert(&hostname).await {
|
||||
warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e);
|
||||
}
|
||||
@@ -375,6 +376,46 @@ pub(super) fn hostname_from_server_name(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-rename side effects that keep the OS consistent with the new
|
||||
/// hostname — all best-effort, the rename itself has already succeeded.
|
||||
/// Debian resolves the local hostname via the 127.0.1.1 line in /etc/hosts;
|
||||
/// leaving the old name there breaks `sudo` ("unable to resolve host") and
|
||||
/// `hostname -f`. And while avahi eventually follows the kernel hostname,
|
||||
/// re-announcing immediately makes http(s)://<hostname>.local links work
|
||||
/// right after the rename instead of minutes later.
|
||||
async fn sync_hostname_side_effects(hostname: &str) {
|
||||
// hostname_from_server_name guarantees [a-z0-9-], safe to interpolate.
|
||||
let script = format!(
|
||||
"if grep -q '^127\\.0\\.1\\.1' /etc/hosts; then sed -i 's/^127\\.0\\.1\\.1.*/127.0.1.1\\t{h}/' /etc/hosts; else printf '127.0.1.1\\t{h}\\n' >> /etc/hosts; fi",
|
||||
h = hostname
|
||||
);
|
||||
match tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/bin/sh", "-c", &script])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => warn!(
|
||||
"/etc/hosts hostname sync failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
|
||||
}
|
||||
|
||||
let republished = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !republished {
|
||||
let _ = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/systemctl", "try-restart", "avahi-daemon"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_system_hostname(hostname: &str) -> Result<()> {
|
||||
let output = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname])
|
||||
|
||||
@@ -33,14 +33,12 @@ impl RpcHandler {
|
||||
validate_service_name(name)?;
|
||||
|
||||
let local_port = if raw_port == 0 {
|
||||
let detected = known_service_port(name);
|
||||
if detected == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unknown app '{}' — specify local_port manually",
|
||||
self.resolve_app_local_port(name).await.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No local web port found for '{}' — the app isn't running or exposes no UI port; specify local_port manually",
|
||||
name
|
||||
));
|
||||
}
|
||||
detected
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
raw_port
|
||||
};
|
||||
@@ -286,7 +284,12 @@ impl RpcHandler {
|
||||
"changed": false,
|
||||
}));
|
||||
}
|
||||
let port = known_service_port(app_id);
|
||||
let port = self.resolve_app_local_port(app_id).await.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No local web port found for '{}' — the app isn't running or exposes no UI port",
|
||||
app_id
|
||||
)
|
||||
})?;
|
||||
let is_proto = is_protocol_service(app_id);
|
||||
config.services.push(TorServiceEntry {
|
||||
name: app_id.to_string(),
|
||||
@@ -330,6 +333,67 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// The host-local port Tor should forward to for an app: the static map
|
||||
/// first (protocol apps like bitcoin must expose 8333, not a UI port),
|
||||
/// then the live launch address the scanner derived from the app's
|
||||
/// published container ports — so any manifest-driven app works without
|
||||
/// a per-app entry.
|
||||
pub(in crate::api::rpc) async fn resolve_app_local_port(&self, name: &str) -> Option<u16> {
|
||||
let known = known_service_port(name);
|
||||
if known != 0 {
|
||||
return Some(known);
|
||||
}
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let lan = data
|
||||
.package_data
|
||||
.get(name)?
|
||||
.installed
|
||||
.as_ref()?
|
||||
.interface_addresses
|
||||
.get("main")?
|
||||
.lan_address
|
||||
.clone()?;
|
||||
crate::api::rpc::container::port_from_url(&lan)
|
||||
}
|
||||
|
||||
/// Best-effort auto-exposure of a freshly installed app as a Tor hidden
|
||||
/// service. Skips protocol services (bitcoin/lnd keep their explicit
|
||||
/// flows), the node's own service, apps that already have one, and apps
|
||||
/// with no resolvable web port. Runs detached after install — it never
|
||||
/// fails the caller, it only logs.
|
||||
pub(in crate::api::rpc) async fn auto_add_tor_service(&self, app_id: &str) {
|
||||
if app_id == "archipelago" || is_protocol_service(app_id) {
|
||||
return;
|
||||
}
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
// The scanner may still be deriving the launch address on slower
|
||||
// nodes; retry for up to ~5 minutes before giving up quietly.
|
||||
for _ in 0..10u32 {
|
||||
let config = load_services_config(&config_dir).await;
|
||||
if config.services.iter().any(|s| s.name == app_id) {
|
||||
return;
|
||||
}
|
||||
if let Some(port) = self.resolve_app_local_port(app_id).await {
|
||||
let params = serde_json::json!({ "name": app_id, "local_port": port });
|
||||
match self.handle_tor_create_service(Some(params)).await {
|
||||
Ok(v) => info!(
|
||||
app = app_id,
|
||||
port,
|
||||
onion = ?v.get("onion_address"),
|
||||
"Auto-created Tor hidden service after install"
|
||||
),
|
||||
Err(e) => warn!(app = app_id, "Auto Tor service creation failed: {}", e),
|
||||
}
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
|
||||
}
|
||||
debug!(
|
||||
app = app_id,
|
||||
"No web port resolved — skipping auto Tor service"
|
||||
);
|
||||
}
|
||||
|
||||
/// Restart Tor daemon (system or container).
|
||||
pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> {
|
||||
info!("Manual Tor restart requested");
|
||||
|
||||
@@ -29,6 +29,11 @@ const BACKUP_DIRS: &[&str] = &[
|
||||
"credentials",
|
||||
"tor-config",
|
||||
"content",
|
||||
// Per-node service secrets. Without these a restored node can't
|
||||
// decrypt identity/lnd_aezeed.enc (the Lightning seed backup is
|
||||
// encrypted with secrets/lnd-wallet-password) or reuse its service
|
||||
// credentials. The archive itself is passphrase-encrypted.
|
||||
"secrets",
|
||||
];
|
||||
|
||||
/// Files within data_dir to include in a full backup.
|
||||
@@ -101,7 +106,9 @@ pub async fn create_full_backup(
|
||||
// Step 4: Write metadata
|
||||
let metadata = BackupMetadata {
|
||||
id: backup_id,
|
||||
version: 2,
|
||||
// v3: archive additionally carries the `secrets` dir (needed to
|
||||
// decrypt identity/lnd_aezeed.enc on a restored node).
|
||||
version: 3,
|
||||
created_at: timestamp,
|
||||
encrypted: true,
|
||||
size_bytes: encrypted.len() as u64,
|
||||
@@ -193,6 +200,14 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
|
||||
.context("Failed to create rollback directory")?;
|
||||
|
||||
for dir_name in BACKUP_DIRS {
|
||||
// Only displace live data the backup will actually replace —
|
||||
// older archives don't contain every current BACKUP_DIRS entry
|
||||
// (e.g. `secrets` was added later), and moving a live dir to
|
||||
// rollback with nothing staged to take its place would DELETE it
|
||||
// at cleanup time.
|
||||
if !staging_dir.join(dir_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(dir_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(dir_name);
|
||||
@@ -207,6 +222,9 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
|
||||
}
|
||||
}
|
||||
for file_name in BACKUP_FILES {
|
||||
if !staging_dir.join(file_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(file_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(file_name);
|
||||
@@ -732,6 +750,46 @@ mod tests {
|
||||
assert!(!bad_result.valid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secrets_dir_rides_backup_and_restore() {
|
||||
// The Lightning seed backup (identity/lnd_aezeed.enc) is encrypted
|
||||
// with secrets/lnd-wallet-password — a backup that omits it can't
|
||||
// recover the Lightning wallet on restore.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "s3cret").unwrap();
|
||||
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "s3cret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restoring_old_backup_without_secrets_keeps_live_secrets() {
|
||||
// Archives created before `secrets` joined BACKUP_DIRS don't stage
|
||||
// one — the restore must NOT displace (and then delete) the node's
|
||||
// live secrets in that case.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
// Backup taken while no secrets dir existed (mimics an old archive).
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
// Live secrets appear afterwards.
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
|
||||
|
||||
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "keep-me");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_and_restore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -475,7 +475,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
|
||||
let podman_health = parse_podman_health(c, &state);
|
||||
let host_ports = host_tcp_ports_from_container(c);
|
||||
let host_port_ready = if host_ports.is_empty() {
|
||||
// Only raw-probe published ports for containers WITHOUT their own
|
||||
// podman healthcheck. The healthcheck is the better signal, and the
|
||||
// bare TCP connect+close is noisy against TLS listeners — LND logged
|
||||
// "http: TLS handshake error … EOF" on every monitor cycle because
|
||||
// this probe hit its REST/gRPC ports and hung up mid-handshake.
|
||||
let host_port_ready = if host_ports.is_empty() || podman_health.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(host_ports_ready(&host_ports).await)
|
||||
|
||||
@@ -210,7 +210,10 @@ impl ArkClient {
|
||||
/// the call itself succeeds).
|
||||
pub async fn spendable_sats(&self) -> Result<u64> {
|
||||
let bal = self.balance().await?;
|
||||
Ok(bal.get("spendable_sat").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
Ok(bal
|
||||
.get("spendable_sat")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
|
||||
@@ -220,7 +223,9 @@ impl ArkClient {
|
||||
|
||||
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
|
||||
pub async fn ark_address(&self) -> Result<String> {
|
||||
let res = self.post("/api/v1/wallet/addresses/next", serde_json::json!({})).await?;
|
||||
let res = self
|
||||
.post("/api/v1/wallet/addresses/next", serde_json::json!({}))
|
||||
.await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
@@ -229,7 +234,9 @@ impl ArkClient {
|
||||
|
||||
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
|
||||
pub async fn onchain_address(&self) -> Result<String> {
|
||||
let res = self.post("/api/v1/onchain/addresses/next", serde_json::json!({})).await?;
|
||||
let res = self
|
||||
.post("/api/v1/onchain/addresses/next", serde_json::json!({}))
|
||||
.await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
@@ -277,7 +284,10 @@ impl ArkClient {
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => self.post("/api/v1/boards/board-all", serde_json::json!({})).await,
|
||||
None => {
|
||||
self.post("/api/v1/boards/board-all", serde_json::json!({}))
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,10 +363,7 @@ pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTra
|
||||
Ok(m) => m,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
movements
|
||||
.iter()
|
||||
.filter_map(movement_to_tx)
|
||||
.collect()
|
||||
movements.iter().filter_map(movement_to_tx).collect()
|
||||
}
|
||||
|
||||
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
|
||||
|
||||
@@ -263,28 +263,38 @@ impl PodmanClient {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
// Stall-aware + retried, mirroring the legacy installer's
|
||||
// pull_one_url_with_progress. The old single-attempt hard 600s wall
|
||||
// clock killed slow-but-progressing pulls, which every orchestrator
|
||||
// stack (btcpay, indeedhub, …) surfaced as "first install fails,
|
||||
// second succeeds" once the layer cache was warm. Podman keeps
|
||||
// completed layers between attempts, so retries resume cheaply.
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const BACKOFF_SECS: [u64; 2] = [5, 15];
|
||||
|
||||
let mut last_err = anyhow::anyhow!("podman pull {image}: no attempt ran");
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match pull_image_stall_aware(image).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Image pull failed for {} (attempt {}/{}): {:#}",
|
||||
image,
|
||||
attempt,
|
||||
MAX_ATTEMPTS,
|
||||
e
|
||||
);
|
||||
last_err = e;
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(
|
||||
BACKOFF_SECS[(attempt - 1) as usize],
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd.arg(image);
|
||||
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(600), // 10 min for large images
|
||||
cmd.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Image pull timed out after 10 minutes"))?
|
||||
.context("Failed to execute podman pull")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
pub async fn create_container(&self, manifest: &AppManifest, name: &str) -> Result<String> {
|
||||
@@ -710,6 +720,155 @@ fn podman_network_settings(
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/// One `podman pull` attempt with a stall-aware budget instead of a hard
|
||||
/// wall clock. A pull is only killed when NOTHING is observably happening —
|
||||
/// no stderr output AND no byte growth in podman's TMPDIR staging dir — for
|
||||
/// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling. Dead
|
||||
/// registries still fail fast (no bytes ever land, so the 3-minute stall
|
||||
/// window is the effective bound), while a slow-but-moving multi-GB pull is
|
||||
/// left alone. Same design as the legacy installer's
|
||||
/// pull_one_url_with_progress (LND "first install fails" fix).
|
||||
async fn pull_image_stall_aware(image: &str) -> Result<()> {
|
||||
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
|
||||
const PULL_MAX_SECS: u64 = 1800;
|
||||
const PULL_POLL_INTERVAL_SECS: u64 = 5;
|
||||
|
||||
// Rootless podman's user namespace makes /var/tmp read-only; stage into
|
||||
// the user's containers tmp (same dir every other pull path uses) — it
|
||||
// doubles as the "bytes are moving" signal for stall detection.
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&user_tmp);
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(image);
|
||||
cmd.env("TMPDIR", &user_tmp);
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
cmd.kill_on_drop(true);
|
||||
let mut child = cmd.spawn().context("Failed to start podman pull")?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
// Seconds-since-start of the last stderr line, updated by the reader
|
||||
// task. Starts at 0 so the stall window opens at spawn.
|
||||
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
// Ring of recent stderr lines so a failed pull reports podman's actual
|
||||
// error, not just "exit status 125".
|
||||
let recent_stderr = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
|
||||
String,
|
||||
>::with_capacity(8)));
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
let line_clock = std::sync::Arc::clone(&last_line_at);
|
||||
let stderr_ring = std::sync::Arc::clone(&recent_stderr);
|
||||
let started_reader = started;
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
line_clock.store(
|
||||
started_reader.elapsed().as_secs(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
if let Ok(mut ring) = stderr_ring.lock() {
|
||||
if ring.len() >= 8 {
|
||||
ring.pop_front();
|
||||
}
|
||||
ring.push_back(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let stderr_tail = |ring: &std::sync::Mutex<std::collections::VecDeque<String>>| {
|
||||
ring.lock()
|
||||
.map(|r| r.iter().cloned().collect::<Vec<_>>().join(" | "))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut last_staged_bytes = dir_size_bytes(&user_tmp);
|
||||
let mut last_staged_change = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if status.success() => return Ok(()),
|
||||
Ok(Some(status)) => {
|
||||
anyhow::bail!(
|
||||
"podman pull {image} failed ({status}): {}",
|
||||
stderr_tail(&recent_stderr)
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
return Err(anyhow::anyhow!("podman pull {image} process error: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
|
||||
|
||||
if started.elapsed() > std::time::Duration::from_secs(PULL_MAX_SECS) {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
anyhow::bail!("podman pull {image} exceeded absolute {PULL_MAX_SECS}s ceiling");
|
||||
}
|
||||
|
||||
// Activity signal 1: stderr output from podman.
|
||||
let line_age = started
|
||||
.elapsed()
|
||||
.as_secs()
|
||||
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
|
||||
// Activity signal 2: staged layer bytes growing in TMPDIR.
|
||||
let staged = dir_size_bytes(&user_tmp);
|
||||
if staged != last_staged_bytes {
|
||||
last_staged_bytes = staged;
|
||||
last_staged_change = std::time::Instant::now();
|
||||
}
|
||||
|
||||
if line_age > PULL_STALL_TIMEOUT_SECS
|
||||
&& last_staged_change.elapsed()
|
||||
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS)
|
||||
{
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
anyhow::bail!(
|
||||
"podman pull {image} stalled ({PULL_STALL_TIMEOUT_SECS}s with no output and no staged bytes): {}",
|
||||
stderr_tail(&recent_stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
|
||||
/// "bytes are moving" signal for pull stall detection — exact size doesn't
|
||||
/// matter, only whether it CHANGES between polls. Errors count as 0.
|
||||
fn dir_size_bytes(path: &str) -> u64 {
|
||||
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
|
||||
if depth > 8 {
|
||||
return 0;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut total = 0u64;
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
total = total.saturating_add(walk(&entry.path(), depth + 1));
|
||||
} else {
|
||||
total = total.saturating_add(meta.len());
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
walk(std::path::Path::new(path), 0)
|
||||
}
|
||||
|
||||
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
||||
let mut ports = Vec::new();
|
||||
if let Some(obj) = bindings.as_object() {
|
||||
|
||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,81 @@
|
||||
# Companion app pairing QR — integration handoff
|
||||
|
||||
**Status:** web-UI side SHIPPED (CompanionIntroOverlay.vue, 2026-07-16). This doc is
|
||||
the contract + requirements for the companion-app side (worked on separately, on
|
||||
the Mac).
|
||||
|
||||
## What the web UI now does
|
||||
|
||||
The "Remote Companion" intro modal (shown once after first dashboard login, and in
|
||||
the public demo) gained a second screen:
|
||||
|
||||
1. **Screen 1 (existing):** APK download QR (desktop) / download button, plus a new
|
||||
**"I've installed it"** button to the right of the download button.
|
||||
2. **Screen 2 (new, slide transition):** a **pairing QR** the companion app scans to
|
||||
auto-fill the server entry, with a **Back** button returning to screen 1. On
|
||||
small screens (where you can't scan your own display) an
|
||||
**"Open in companion app"** deep-link button is shown above Back, using the same
|
||||
URI as the QR.
|
||||
|
||||
## The QR payload / deep link (the contract)
|
||||
|
||||
A single URI, also usable as an OS deep link:
|
||||
|
||||
```
|
||||
archipelago://pair?v=1&url=<percent-encoded server URL>[&pw=<percent-encoded password>]
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
| param | required | meaning |
|
||||
|-------|----------|---------|
|
||||
| `v` | yes | Payload version, currently `1`. Reject/ignore unknown majors gracefully — show "please update the app". |
|
||||
| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.168.1.228`, etc. No trailing slash guaranteed either way — normalize. |
|
||||
| `pw` | no | Login password. **Only present in the public demo** (shared demo password `entertoexit`). Real nodes never embed a password — the frontend doesn't have it. |
|
||||
|
||||
Examples the web UI actually emits:
|
||||
|
||||
- Demo: `archipelago://pair?v=1&url=https%3A%2F%2Fdemo.archipelago-foundation.org&pw=entertoexit`
|
||||
- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.168.1.228`
|
||||
- Real node kiosk (UI runs on localhost, so it advertises the mDNS name from
|
||||
`system.get-hostname`): `archipelago://pair?v=1&url=http%3A%2F%2Farchipelago.local`
|
||||
|
||||
## Companion app requirements
|
||||
|
||||
1. **Scan entry point:** the app's action is labeled **"Scan Node's QR"**
|
||||
(implemented 2026-07-17; the modal copy in CompanionIntroOverlay.vue was
|
||||
updated to match).
|
||||
2. **Parse the URI** (from camera scan AND from an OS deep-link intent —
|
||||
register the `archipelago://` scheme so the "Open in companion app" button on
|
||||
phones works).
|
||||
3. On success, **create/update a saved server entry**:
|
||||
- Server address = `url` exactly as given (respect the scheme — the demo is
|
||||
https, LAN nodes are typically http, `.local` mDNS names must work).
|
||||
- If `pw` present, prefill the password and attempt auto-login; otherwise land
|
||||
on the password prompt for that server.
|
||||
- If an entry with the same origin already exists, update it rather than
|
||||
duplicating.
|
||||
4. **Demo flow (the showcase):** scanning the demo QR should take a fresh install
|
||||
to a logged-in demo session in one step — url `https://demo.archipelago-foundation.org`,
|
||||
password `entertoexit`, no manual typing.
|
||||
5. **Robustness:**
|
||||
- Tolerate unknown extra query params (forward compat — we may add `name`,
|
||||
`cert` fingerprint, etc. under `v=1`).
|
||||
- Self-signed HTTPS on `.local`/LAN addresses may appear later; don't hard-fail
|
||||
the parse on scheme.
|
||||
- Bad/foreign QR → clear error, stay on the scan screen.
|
||||
|
||||
## Notes / future extensions (not in v1)
|
||||
|
||||
- A real-node pairing **token** instead of a password (backend mints a one-time
|
||||
token, QR carries it, app exchanges it for a session) — needs a backend RPC;
|
||||
the `v` param exists so we can bump when this lands.
|
||||
- Optional `name` param (node display name) so the app labels the entry nicely.
|
||||
|
||||
## Testing checklist (app side)
|
||||
|
||||
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
|
||||
- [ ] Scan a real node's QR (LAN IP origin) → entry created, password prompt shown.
|
||||
- [ ] Scan a kiosk node's QR (`http://<name>.local`) → mDNS resolution works on the phone.
|
||||
- [ ] Tap "Open in companion app" on a phone browser → deep link opens the app with the same behavior.
|
||||
- [ ] Re-scan same node → no duplicate entry.
|
||||
@@ -1349,21 +1349,29 @@ if [ "$UNBUNDLED" = "1" ]; then
|
||||
# Clean stale images from previous builds (e.g. bundled build tars leaking into unbundled)
|
||||
rm -rf "$IMAGES_DIR"
|
||||
mkdir -p "$IMAGES_DIR"
|
||||
# FileBrowser is a core dependency (powers the Cloud file manager) — always bundle it
|
||||
CORE_IMAGE="${FILEBROWSER_IMAGE}"
|
||||
CORE_FILE="filebrowser.tar"
|
||||
if [ -f "$IMAGES_DIR/$CORE_FILE" ]; then
|
||||
echo " ✅ Using cached: $CORE_FILE"
|
||||
else
|
||||
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
|
||||
if container_pull "$CORE_IMAGE"; then
|
||||
$CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
|
||||
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
|
||||
echo " ⚠️ Failed to save $CORE_IMAGE"
|
||||
# Core baseline apps created by first-boot-containers.sh even in
|
||||
# unbundled mode — their images must ride on the ISO so a fresh install
|
||||
# works with no internet: FileBrowser (Cloud file manager) and fmcd
|
||||
# (fedimint-clientd, ecash/sats out of the box).
|
||||
CORE_BUNDLE="
|
||||
${FILEBROWSER_IMAGE} filebrowser.tar
|
||||
${FMCD_IMAGE} fmcd.tar
|
||||
"
|
||||
echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do
|
||||
[ -n "$CORE_IMAGE" ] || continue
|
||||
if [ -f "$IMAGES_DIR/$CORE_FILE" ]; then
|
||||
echo " ✅ Using cached: $CORE_FILE"
|
||||
else
|
||||
echo " ⚠️ Failed to pull $CORE_IMAGE — Cloud will not work until installed"
|
||||
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
|
||||
if container_pull "$CORE_IMAGE"; then
|
||||
$CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
|
||||
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
|
||||
echo " ⚠️ Failed to save $CORE_IMAGE"
|
||||
else
|
||||
echo " ⚠️ Failed to pull $CORE_IMAGE — baseline app won't work offline"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "📦 Step 3b: Bundling container images for offline use..."
|
||||
|
||||
@@ -1472,6 +1480,13 @@ LOADSERVICE
|
||||
cat > "$WORK_DIR/load-container-images.sh" <<'LOADSCRIPT'
|
||||
#!/bin/bash
|
||||
# Load pre-bundled container images into Podman
|
||||
#
|
||||
# CRITICAL: all Archipelago containers run ROOTLESS as the archipelago user.
|
||||
# This script runs as root (systemd oneshot), so a plain `podman load` here
|
||||
# puts the images into root's storage where the rootless runtime can never
|
||||
# see them — containers then silently depend on registry pulls, and a fresh
|
||||
# install without internet gets no apps at all. Always load into the
|
||||
# archipelago user's storage.
|
||||
|
||||
IMAGES_DIR="/opt/archipelago/container-images"
|
||||
LOG_FILE="/var/log/archipelago-images.log"
|
||||
@@ -1483,21 +1498,32 @@ if [ ! -d "$IMAGES_DIR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ARCH_UID=$(id -u archipelago)
|
||||
# Linger gives the archipelago user a runtime dir (/run/user/UID) at boot,
|
||||
# before any login — required for rootless podman.
|
||||
loginctl enable-linger archipelago 2>/dev/null || true
|
||||
for _ in $(seq 1 30); do
|
||||
[ -d "/run/user/$ARCH_UID" ] && break
|
||||
sleep 1
|
||||
done
|
||||
PODMAN="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$ARCH_UID podman"
|
||||
$PODMAN system migrate >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
for tarfile in "$IMAGES_DIR"/*.tar; do
|
||||
if [ -f "$tarfile" ]; then
|
||||
echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE"
|
||||
podman load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
|
||||
$PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
|
||||
echo "$(date): Successfully loaded $(basename "$tarfile")" >> "$LOG_FILE" || \
|
||||
echo "$(date): Failed to load $(basename "$tarfile")" >> "$LOG_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure archy-net exists for mempool stack (db, api, frontend)
|
||||
podman network create archy-net 2>/dev/null || true
|
||||
$PODMAN network create archy-net 2>/dev/null || true
|
||||
|
||||
echo "$(date): Container image load complete" >> "$LOG_FILE"
|
||||
echo "$(date): Available images:" >> "$LOG_FILE"
|
||||
podman images >> "$LOG_FILE" 2>&1
|
||||
$PODMAN images >> "$LOG_FILE" 2>&1
|
||||
LOADSCRIPT
|
||||
|
||||
chmod +x "$WORK_DIR/load-container-images.sh"
|
||||
@@ -3587,10 +3613,27 @@ echo " Step 5 complete (GRUB + ISOLINUX configured)"
|
||||
echo ""
|
||||
echo "Step 6: Creating bootable ISO..."
|
||||
|
||||
if [ "$UNBUNDLED" = "1" ]; then
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}.iso"
|
||||
# Release-candidate suffix: rebuilds of the SAME version used to overwrite
|
||||
# the previous ISO under an identical filename, so a flashed stick was
|
||||
# indistinguishable from a newer build. Auto-increment an RC counter per
|
||||
# version (persists next to the build counter); override with RC=n env.
|
||||
RC_COUNTER_FILE="/opt/archipelago/rc-counter-${BUILD_VERSION}"
|
||||
if [ -n "${RC:-}" ]; then
|
||||
RC_NUM="$RC"
|
||||
else
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}.iso"
|
||||
if [ -f "$RC_COUNTER_FILE" ]; then
|
||||
RC_NUM=$(( $(cat "$RC_COUNTER_FILE") + 1 ))
|
||||
else
|
||||
RC_NUM=1
|
||||
fi
|
||||
fi
|
||||
echo "$RC_NUM" | sudo tee "$RC_COUNTER_FILE" > /dev/null 2>/dev/null || true
|
||||
echo " Release candidate: RC${RC_NUM}"
|
||||
|
||||
if [ "$UNBUNDLED" = "1" ]; then
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}_RC${RC_NUM}.iso"
|
||||
else
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}_RC${RC_NUM}.iso"
|
||||
fi
|
||||
|
||||
# Use the proven MBR code for hybrid USB boot
|
||||
|
||||
@@ -135,7 +135,7 @@ while true; do
|
||||
--disable-metrics \
|
||||
--disable-metrics-reporting \
|
||||
--disable-domain-reliability \
|
||||
--js-flags="--max-old-space-size=128" \
|
||||
--js-flags="--max-old-space-size=256" \
|
||||
--user-data-dir=/var/lib/archipelago/chromium-kiosk
|
||||
sleep 3
|
||||
done
|
||||
|
||||
@@ -21,7 +21,10 @@ EnvironmentFile=-/var/lib/archipelago/telemetry.env
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
||||
# + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths)
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
|
||||
# Host IP from the main-table default route — hostname -I token order breaks
|
||||
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
|
||||
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
|
||||
ExecStart=/usr/local/bin/archipelago
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -23,6 +23,10 @@ COPY docker/lnd-ui /docker/lnd-ui
|
||||
COPY docker/fedimint-ui /docker/fedimint-ui
|
||||
COPY demo/files /demo/files
|
||||
COPY demo/content /demo/content
|
||||
# Peer catalog media (posters/covers/photos for content.browse-peer mocks) —
|
||||
# deliberately OUTSIDE demo/content so it doesn't appear as the visitor's own
|
||||
# cloud files.
|
||||
COPY demo/peer-media /demo/peer-media
|
||||
|
||||
# This image only ever serves the public demo — scrub the private release/registry
|
||||
# server address from everything it serves (mock data, catalog.json, demo assets)
|
||||
|
||||
@@ -187,8 +187,9 @@ http {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
# Cache static assets (media too — the intro video/audio are versioned
|
||||
# with ?v=N query busters, so immutable is safe)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|mp4|webm|mp3|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* End-to-end verification of the FIRST-VISIT cinematic:
|
||||
* splash (tap logo → alien typing → Welcome Noderunner + speech + synthwave
|
||||
* → Archipelago logo) → onboarding intro → login (video background, finale
|
||||
* armed) → dashboard full reveal (background zoom + interface assembly +
|
||||
* oomph) → welcome typing — and that a SECOND login stays deliberately
|
||||
* low-key.
|
||||
*
|
||||
* Run against the local demo stack:
|
||||
* DEMO=1 node mock-backend.js (port 5959)
|
||||
* VITE_DEMO=1 npx vite --port 8100
|
||||
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
|
||||
*
|
||||
* Audio can't be heard headless, so every HTMLMediaElement.play() and
|
||||
* WebAudio oscillator start is recorded into window.__audioLog via an init
|
||||
* script — the assertions check the right cues fired at the right phases.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
// Real Chrome (new headless): the bundled headless-shell has no working video
|
||||
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
|
||||
// meaningless there. Requires Google Chrome installed.
|
||||
test.use({ channel: 'chrome' })
|
||||
|
||||
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__audioLog: string[]
|
||||
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
|
||||
__revealSeen: { zoom?: boolean; glass?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
async function instrumentAudioAndVideo(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.__audioLog = []
|
||||
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
|
||||
|
||||
// The dashboard reveal classes live only ~8s; a slow run can burn that
|
||||
// window between two sequential expect() polls. Record their appearance
|
||||
// the moment it happens instead, and let the test assert on the record.
|
||||
window.__revealSeen = {}
|
||||
new MutationObserver(() => {
|
||||
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
|
||||
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
|
||||
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
const origPlay = HTMLMediaElement.prototype.play
|
||||
HTMLMediaElement.prototype.play = function (...args) {
|
||||
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
|
||||
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
|
||||
if (this.tagName === 'VIDEO') {
|
||||
const v = this as HTMLVideoElement
|
||||
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
|
||||
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
|
||||
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
|
||||
}
|
||||
return origPlay.apply(this, args)
|
||||
}
|
||||
|
||||
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
|
||||
const OrigOsc = OscillatorNode.prototype.start
|
||||
OscillatorNode.prototype.start = function (...args) {
|
||||
window.__audioLog.push('osc-start')
|
||||
return OrigOsc.apply(this, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function freshVisit(page: Page) {
|
||||
await page.goto(BASE + '/')
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
|
||||
await page.goto(BASE + '/')
|
||||
}
|
||||
|
||||
/** Walk the full splash from tap-to-start through completion (real time). */
|
||||
async function runSplash(page: Page, { skip }: { skip: boolean }) {
|
||||
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
|
||||
// on click, so don't wait for post-click actionability)
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
|
||||
|
||||
// Phase 2: alien typing begins (first line types out)
|
||||
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
|
||||
.toBeVisible({ timeout: 10_000 })
|
||||
|
||||
if (skip) {
|
||||
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
|
||||
} else {
|
||||
// Let all four lines type out for real (~20s)
|
||||
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
|
||||
}
|
||||
|
||||
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
|
||||
// text is only on screen ~6s, so verify the phases via durable signals:
|
||||
// the video's live health here, and the audio log (speech/song) after.
|
||||
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
|
||||
// Wait for actual smooth playback (cold-cache buffering right after mount
|
||||
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
|
||||
await page.waitForFunction(() => {
|
||||
const v = document.querySelector('video')
|
||||
return !!v && v.readyState >= 3 && v.currentTime > 0.3
|
||||
}, { timeout: 20_000 })
|
||||
const s1 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')!
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
await page.waitForTimeout(2_000)
|
||||
const s2 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')
|
||||
if (!v) return null
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
expect(s2).not.toBeNull()
|
||||
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
|
||||
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
|
||||
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
|
||||
// Steady-state frame drops over the sample window (startup catch-up excluded).
|
||||
const dTotal = s2!.total - s1.total
|
||||
const dDropped = s2!.dropped - s1.dropped
|
||||
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
|
||||
|
||||
// Splash completes → demo routes to the onboarding intro
|
||||
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
|
||||
}
|
||||
|
||||
async function enterDemoAndLogin(page: Page) {
|
||||
// The CTA unmounts mid-click when the router transitions away — dispatch
|
||||
// once, swallow the detach retry, and trust the URL change instead.
|
||||
const cta = page.getByRole('button', { name: /Enter the demo/ })
|
||||
await cta.waitFor({ timeout: 15_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/login', { timeout: 15_000 }),
|
||||
cta.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Demo prefills the password; the finale flag must be armed at this point.
|
||||
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
|
||||
const loginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await loginBtn.waitFor({ timeout: 10_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
}
|
||||
|
||||
test.describe('first-visit cinematic', () => {
|
||||
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: false })
|
||||
|
||||
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
|
||||
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// FULL first-entry reveal: big background zoom + glass assembly classes
|
||||
// (recorded by the init-script observer the instant they appear — the
|
||||
// classes only live ~8s and sequential polling can miss the window).
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
|
||||
.toMatchObject({ zoom: true, glass: true })
|
||||
|
||||
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
|
||||
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// The reveal runs 8s, then the zoom layer class clears.
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
|
||||
|
||||
// The dashboard oomph is WebAudio oscillators — at least the login pop +
|
||||
// oomph layers must have started after the splash's own sounds.
|
||||
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
|
||||
expect(oscCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: true })
|
||||
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
|
||||
// End the session the way logout does (auth token only — the intro/login
|
||||
// flags survive) and revisit login directly. An authenticated /login visit
|
||||
// would just bounce back to the dashboard via the router guard.
|
||||
await page.evaluate(() => localStorage.removeItem('neode-auth'))
|
||||
// Direct /login navigation (no splash — not a root boot) and re-login.
|
||||
await page.goto(BASE + '/login')
|
||||
await page.locator('#login-password').waitFor({ timeout: 15_000 })
|
||||
// First login happened → static rotated background, not the video.
|
||||
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
|
||||
|
||||
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
|
||||
// reset with the /login reload, so it would have caught even a transient
|
||||
// reveal since then.
|
||||
await page.waitForTimeout(1500)
|
||||
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// Reload at root = a fresh boot → the splash must return even though
|
||||
// neode_intro_seen is now set.
|
||||
await page.goto(BASE + '/')
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('video is warmed before the splash needs it', async ({ page }) => {
|
||||
await freshVisit(page)
|
||||
// The warm-up is a detached <video preload=auto> kept on window (Chromium
|
||||
// has no <link rel=preload as=video>). Give it a moment to buffer.
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => {
|
||||
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
|
||||
return w ? { src: w.src, readyState: w.readyState } : null
|
||||
}), { timeout: 15_000 })
|
||||
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
|
||||
const ready = await page.evaluate(() =>
|
||||
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
|
||||
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
|
||||
})
|
||||
})
|
||||
@@ -1201,6 +1201,97 @@ function demoAppShell(title, sub, iconPath, bodyHtml) {
|
||||
</div><div class="demo-tag">Archipelago demo · signet</div></body></html>`
|
||||
}
|
||||
|
||||
// ── Rich peer content library (demo) ─────────────────────────────────────────
|
||||
// What a healthy decentralised network's shared catalog looks like: films,
|
||||
// series, books, music, photography and documents with real metadata. Music,
|
||||
// photos and documents are backed by REAL committed files (they play/open);
|
||||
// films/series/books are paid so the buy-flow poster/cover previews carry the
|
||||
// visuals. Preview images live in demo/peer-media (sourced from the web via
|
||||
// picsum.photos — Unsplash-licensed — seeds pinned in git history).
|
||||
const PEER_MEDIA = (f) => path.join(__dirname, '..', 'demo', 'peer-media', f)
|
||||
const PEER_CONTENT = (...p) => path.join(__dirname, '..', 'demo', 'content', ...p)
|
||||
const PEER_LIBRARY = [
|
||||
// Films & series — paid, poster previews
|
||||
{ id: 'film-block-height', filename: 'Block Height (2025) 2160p.mp4', mime_type: 'video/mp4', size_bytes: 4_512_374_784, access: { paid: { price_sats: 2500 } }, preview: PEER_MEDIA('poster-film-block.jpg'),
|
||||
description: 'Documentary · 2025 · 1h 42m · 3840×2160 HEVC 10-bit · dir. M. Castillo — three node operators ride out a halving epoch.' },
|
||||
{ id: 'film-the-signal', filename: 'The Signal (2024) 1080p.mkv', mime_type: 'video/x-matroska', size_bytes: 2_147_483_648, access: { paid: { price_sats: 2100 } }, preview: PEER_MEDIA('poster-film-signal.jpg'),
|
||||
description: 'Thriller · 2024 · 2h 08m · 1920×1080 x264 · A radio engineer discovers her mesh network is carrying more than messages.' },
|
||||
{ id: 'film-meshwork', filename: 'Meshwork — infrastructure for the willing (720p).mp4', mime_type: 'video/mp4', size_bytes: 891_289_600, access: { paid: { price_sats: 800 } }, preview: PEER_MEDIA('poster-film-mesh.jpg'),
|
||||
description: 'Documentary short · 2026 · 34m · 1280×720 · Community LoRa + Reticulum builds across the highlands.' },
|
||||
{ id: 'series-node-runners-s01e01', filename: 'Node Runners S01E01 — Genesis.mkv', mime_type: 'video/x-matroska', size_bytes: 734_003_200, access: { paid: { price_sats: 500 } }, preview: PEER_MEDIA('poster-series-node.jpg'),
|
||||
description: 'Series · S01E01 · 43m · 1080p · Docuseries following first-time sovereign node builders.' },
|
||||
// Books — paid, cover previews
|
||||
{ id: 'book-sovereign-notes', filename: 'The Sovereign Individual — annotated.epub', mime_type: 'application/epub+zip', size_bytes: 3_842_048, access: { paid: { price_sats: 300 } }, preview: PEER_MEDIA('cover-book-sovereign.jpg'),
|
||||
description: 'EPUB · 512 pp · community margin-notes edition, 2026 · Davidson & Rees-Mogg.' },
|
||||
{ id: 'book-cypherpunk-essays', filename: 'Cypherpunk Essays Vol. II.epub', mime_type: 'application/epub+zip', size_bytes: 2_097_152, access: { paid: { price_sats: 210 } }, preview: PEER_MEDIA('cover-book-cypher.jpg'),
|
||||
description: 'EPUB · 348 pp · 2025 anthology — privacy engineering, remailers, digital cash history.' },
|
||||
{ id: 'book-broadcast-power', filename: 'Broadcast Power — pirate radio to mesh.pdf', mime_type: 'application/pdf', size_bytes: 18_874_368, access: { paid: { price_sats: 150 } }, preview: PEER_MEDIA('cover-book-broadcast.jpg'),
|
||||
description: 'PDF · 214 pp · illustrated 2024 history of community-run transmission networks.' },
|
||||
// Music — free, streams the real committed files
|
||||
{ id: 'song-leaves-brown', filename: 'All the leaves are brown.mp3', mime_type: 'audio/mpeg', size_bytes: 2_936_012, access: 'free', disk: PEER_CONTENT('music', 'All the leaves are brown.mp3'),
|
||||
description: 'MP3 320 kbps · Archy Sessions, 2026 · folk cover, one-take living-room recording.' },
|
||||
{ id: 'song-exploited-substrate', filename: 'An Exploited Substrate.mp3', mime_type: 'audio/mpeg', size_bytes: 4_194_304, access: 'free', disk: PEER_CONTENT('music', 'An Exploited Substrate.mp3'),
|
||||
description: 'MP3 320 kbps · Node Noise EP, 2025 · instrumental synthwave.' },
|
||||
{ id: 'song-architects', filename: 'Architects of Tomorrow.wav', mime_type: 'audio/wav', size_bytes: 34_734_080, access: 'free', disk: PEER_CONTENT('music', 'Architects of Tomorrow.wav'),
|
||||
description: 'WAV 16-bit/44.1 kHz lossless master · Node Noise EP, 2025.' },
|
||||
{ id: 'song-bitcoin-salad', filename: 'Bitcoin Salad.MP3', mime_type: 'audio/mpeg', size_bytes: 3_984_588, access: 'free', disk: PEER_CONTENT('music', 'Bitcoin Salad.MP3'),
|
||||
description: 'MP3 256 kbps · Kitchen Mix Vol. 3, 2024 · novelty electro.' },
|
||||
{ id: 'song-builders', filename: 'Builders, not talkers (Remastered).mp3', mime_type: 'audio/mpeg', size_bytes: 4_508_876, access: 'free', disk: PEER_CONTENT('music', 'Builders, not talkers (Remastered).mp3'),
|
||||
description: 'MP3 320 kbps · 2026 remaster · hip-hop instrumental.' },
|
||||
// Photography — free, the committed JPEGs themselves (preview = full image)
|
||||
{ id: 'photo-aurora-fjord', filename: 'aurora-over-the-fjord.jpg', mime_type: 'image/jpeg', size_bytes: 79_422, access: 'free', disk: PEER_MEDIA('photo-aurora-fjord.jpg'),
|
||||
description: '800×533 · f/1.8 · 8s · ISO 1600 · Sony A7 IV 24mm · Lofoten, Norway · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-mountain-lake', filename: 'mountain-lake-still.jpg', mime_type: 'image/jpeg', size_bytes: 29_200, access: 'free', disk: PEER_MEDIA('photo-mountain-lake.jpg'),
|
||||
description: '800×533 · f/8 · 1/250s · ISO 100 · Fuji X-T5 23mm · Dolomites, Italy · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-city-neon', filename: 'city-neon-rain.jpg', mime_type: 'image/jpeg', size_bytes: 59_855, access: 'free', disk: PEER_MEDIA('photo-city-neon.jpg'),
|
||||
description: '800×533 · f/1.4 · 1/60s · ISO 800 · Leica Q3 28mm · Osaka, Japan · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-desert-dunes', filename: 'desert-dunes-dawn.jpg', mime_type: 'image/jpeg', size_bytes: 24_646, access: 'free', disk: PEER_MEDIA('photo-desert-dunes.jpg'),
|
||||
description: '800×533 · f/11 · 1/125s · ISO 64 · Nikon Z7 II 70-200mm · Erg Chebbi, Morocco · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-forest-mist', filename: 'forest-mist-morning.jpg', mime_type: 'image/jpeg', size_bytes: 48_504, access: 'free', disk: PEER_MEDIA('photo-forest-mist.jpg'),
|
||||
description: '800×533 · f/4 · 1/200s · ISO 400 · Canon R5 85mm · Black Forest, Germany · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-ocean-cliff', filename: 'ocean-cliff-long-exposure.jpg', mime_type: 'image/jpeg', size_bytes: 30_517, access: 'free', disk: PEER_MEDIA('photo-ocean-cliff.jpg'),
|
||||
description: '800×533 · f/16 · 30s ND1000 · ISO 50 · Sony A7R V 16-35mm · Moher, Ireland · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-northern-road', filename: 'northern-road-solitude.jpg', mime_type: 'image/jpeg', size_bytes: 72_053, access: 'free', disk: PEER_MEDIA('photo-northern-road.jpg'),
|
||||
description: '800×533 · f/5.6 · 1/500s · ISO 200 · drone DJI Mavic 3 · Iceland Ring Road · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-autumn-valley', filename: 'autumn-valley-patchwork.jpg', mime_type: 'image/jpeg', size_bytes: 51_148, access: 'free', disk: PEER_MEDIA('photo-autumn-valley.jpg'),
|
||||
description: '800×533 · f/9 · 1/160s · ISO 100 · Fuji GFX 100S 110mm · Vermont, USA · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-harbor-dawn', filename: 'harbor-dawn-fishing-boats.jpg', mime_type: 'image/jpeg', size_bytes: 36_271, access: 'free', disk: PEER_MEDIA('photo-harbor-dawn.jpg'),
|
||||
description: '800×533 · f/2.8 · 1/80s · ISO 320 · Leica M11 35mm · Cornwall, UK · free license, sourced via picsum.photos.' },
|
||||
{ id: 'photo-alpine-ridge', filename: 'alpine-ridge-hiker.jpg', mime_type: 'image/jpeg', size_bytes: 91_938, access: 'free', disk: PEER_MEDIA('photo-alpine-ridge.jpg'),
|
||||
description: '800×533 · f/7.1 · 1/320s · ISO 100 · Canon R6 II 24-70mm · Bernese Oberland, Switzerland · free license, sourced via picsum.photos.' },
|
||||
// Documents — free, real committed files
|
||||
{ id: 'doc-whitepaper-notes', filename: 'bitcoin-whitepaper-notes.md', mime_type: 'text/markdown', size_bytes: 8_192, access: 'free', disk: PEER_CONTENT('documents', 'bitcoin-whitepaper-notes.md'),
|
||||
description: 'Markdown study notes on the Bitcoin whitepaper, section by section.' },
|
||||
{ id: 'doc-sovereignty-manifesto', filename: 'sovereignty-manifesto.txt', mime_type: 'text/plain', size_bytes: 4_096, access: 'free', disk: PEER_CONTENT('documents', 'sovereignty-manifesto.txt'),
|
||||
description: 'Plain-text manifesto on self-hosted infrastructure.' },
|
||||
{ id: 'doc-node-checklist', filename: 'node-setup-checklist.md', mime_type: 'text/markdown', size_bytes: 6_144, access: 'free', disk: PEER_CONTENT('documents', 'node-setup-checklist.md'),
|
||||
description: 'Step-by-step hardening checklist for a fresh Archipelago node.' },
|
||||
{ id: 'doc-lightning-csv', filename: 'lightning-channels.csv', mime_type: 'text/csv', size_bytes: 2_048, access: 'free', disk: PEER_CONTENT('documents', 'lightning-channels.csv'),
|
||||
description: 'CSV export — public channel set with capacities and fee policies.' },
|
||||
// Software
|
||||
{ id: 'sw-archy-iso', filename: 'archipelago-1.7.99-amd64.iso', mime_type: 'application/x-iso9660-image', size_bytes: 2_684_354_560, access: 'free',
|
||||
description: 'Archipelago Node OS installer ISO · amd64 · SHA256 in release notes.' },
|
||||
]
|
||||
|
||||
/** Deterministic per-peer slice of the library — every peer shares a different
|
||||
* handful, popular items appear on more than one node (it's a network). */
|
||||
function peerCatalogFor(onion) {
|
||||
let seed = 0
|
||||
for (const c of String(onion)) seed = (seed * 31 + c.charCodeAt(0)) >>> 0
|
||||
const slot = seed % 12
|
||||
return PEER_LIBRARY
|
||||
.map((item, i) => ({ item, i }))
|
||||
.filter(({ i }) => i % 12 === slot || ((seed ^ (i * 2654435761)) >>> 0) % 12 < 3)
|
||||
.map(({ item }) => ({
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
mime_type: item.mime_type,
|
||||
size_bytes: item.size_bytes,
|
||||
description: item.description,
|
||||
access: item.access,
|
||||
}))
|
||||
}
|
||||
|
||||
// 12 trusted/federated nodes for the demo Federation view.
|
||||
function demoFederationNodes() {
|
||||
const names = [
|
||||
@@ -1941,6 +2032,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
about: 'Self-sovereign Bitcoin node',
|
||||
nip05: 'satoshi@archipelago.local',
|
||||
lud16: 'satoshi@getalby.com',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1953,7 +2045,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
is_default: false,
|
||||
nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
|
||||
nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz',
|
||||
profile: { display_name: 'Anon' },
|
||||
profile: { display_name: 'Anon', picture: '/demo-avatars/anon.svg' },
|
||||
},
|
||||
{
|
||||
id: 'id-merchant',
|
||||
@@ -1965,7 +2057,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
is_default: false,
|
||||
nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def',
|
||||
profile: { display_name: 'My Shop', website: 'https://myshop.onion' },
|
||||
profile: { display_name: 'My Shop', website: 'https://myshop.onion', picture: '/demo-avatars/business.svg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1988,6 +2080,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
profile: {
|
||||
display_name: 'Archipelago Node',
|
||||
about: 'Self-sovereign Bitcoin node',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2030,6 +2123,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
about: 'Running a sovereign Bitcoin node',
|
||||
nip05: 'satoshi@archipelago.local',
|
||||
lud16: 'satoshi@getalby.com',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2045,6 +2139,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
profile: {
|
||||
display_name: 'Archy Consulting',
|
||||
about: 'Bitcoin infrastructure services',
|
||||
picture: '/demo-avatars/business.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2055,7 +2150,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs',
|
||||
created_at: '2026-03-01T18:00:00Z',
|
||||
is_default: false,
|
||||
profile: {},
|
||||
profile: { picture: '/demo-avatars/anon.svg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2072,7 +2167,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
|
||||
is_default: true, created_at: '2026-01-10T08:00:00Z',
|
||||
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' },
|
||||
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node', picture: '/demo-avatars/satoshi.svg' },
|
||||
},
|
||||
}
|
||||
return res.json({ result: identities[id] || identities['id-primary'] })
|
||||
@@ -2144,12 +2239,40 @@ app.post('/rpc/v1', (req, res) => {
|
||||
bytes_out: 642_889_310,
|
||||
} })
|
||||
}
|
||||
// Fake tunnel provisioning so the companion remote-access onboarding
|
||||
// (CompanionIntroOverlay) walks the same steps as a real node. The keys
|
||||
// are not real; importing this config yields a harmless dead tunnel.
|
||||
case 'vpn.list-peers': {
|
||||
return res.json({ result: { peers: mockState.vpnPeers || [] } })
|
||||
}
|
||||
case 'vpn.create-peer':
|
||||
case 'vpn.peer-config': {
|
||||
const peerName = params?.name || 'device'
|
||||
if (!mockState.vpnPeers) mockState.vpnPeers = []
|
||||
if (!mockState.vpnPeers.some((p) => p.name === peerName)) {
|
||||
mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' })
|
||||
}
|
||||
const config = [
|
||||
'[Interface]',
|
||||
'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=',
|
||||
'Address = 10.44.0.7/32',
|
||||
'DNS = 1.1.1.1',
|
||||
'',
|
||||
'[Peer]',
|
||||
'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=',
|
||||
'Endpoint = demo.archipelago-foundation.org:51820',
|
||||
'AllowedIPs = 10.44.0.0/16',
|
||||
'PersistentKeepalive = 25',
|
||||
].join('\n')
|
||||
return res.json({ result: { config, peer_ip: '10.44.0.7' } })
|
||||
}
|
||||
|
||||
// Node visibility — interactive (persisted per demo session)
|
||||
case 'network.get-visibility': {
|
||||
// No onion_address in the demo: the public showcase shouldn't display
|
||||
// a Tor address (even a fake one) on the Node Visibility card.
|
||||
return res.json({ result: {
|
||||
visibility: mockState.nodeVisibility,
|
||||
onion_address: mockData['server-info']['tor-address'],
|
||||
} })
|
||||
}
|
||||
case 'network.set-visibility': {
|
||||
@@ -2157,7 +2280,6 @@ app.post('/rpc/v1', (req, res) => {
|
||||
if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v
|
||||
return res.json({ result: {
|
||||
visibility: mockState.nodeVisibility,
|
||||
onion_address: mockData['server-info']['tor-address'],
|
||||
} })
|
||||
}
|
||||
|
||||
@@ -2166,15 +2288,40 @@ app.post('/rpc/v1', (req, res) => {
|
||||
// =========================================================================
|
||||
case 'content.browse-peer': {
|
||||
const onion = params?.onion || ''
|
||||
return res.json({
|
||||
result: {
|
||||
items: [
|
||||
{ id: 'peer-doc-1', filename: 'Bitcoin Whitepaper.pdf', mime_type: 'application/pdf', size_bytes: 184292, description: 'The original Bitcoin whitepaper by Satoshi Nakamoto', access: 'free' },
|
||||
{ id: 'peer-img-1', filename: 'node-setup-guide.png', mime_type: 'image/png', size_bytes: 524800, description: 'Visual guide for setting up a Bitcoin node', access: 'free' },
|
||||
{ id: 'peer-vid-1', filename: 'Lightning Demo.mp4', mime_type: 'video/mp4', size_bytes: 15728640, description: 'Lightning Network payment channel demo', access: { paid: { price_sats: 500 } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
return res.json({ result: { items: peerCatalogFor(onion) } })
|
||||
}
|
||||
|
||||
case 'content.preview-peer': {
|
||||
// Serve the item's committed preview image (film poster, book cover, or
|
||||
// the photo itself) so the demo NEVER renders a broken thumbnail.
|
||||
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
|
||||
const previewAbs = entry && (entry.preview || (entry.mime_type.startsWith('image/') && entry.disk))
|
||||
if (previewAbs) {
|
||||
try {
|
||||
const data = fsSync.readFileSync(typeof previewAbs === 'string' ? previewAbs : entry.disk)
|
||||
return res.json({ result: { data: data.toString('base64'), content_type: 'image/jpeg' } })
|
||||
} catch { /* fall through to no preview (icon fallback) */ }
|
||||
}
|
||||
return res.json({ result: {} })
|
||||
}
|
||||
|
||||
case 'content.download-peer': {
|
||||
// Free items backed by a real committed file stream the actual bytes —
|
||||
// music plays, photos open, documents read. Anything else gets the
|
||||
// demo placeholder note.
|
||||
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
|
||||
if (entry?.disk) {
|
||||
try {
|
||||
const data = fsSync.readFileSync(entry.disk)
|
||||
return res.json({ result: { data: data.toString('base64'), content_type: entry.mime_type } })
|
||||
} catch { /* fall through to placeholder */ }
|
||||
}
|
||||
const placeholder = Buffer.from(
|
||||
`Archipelago demo — "${entry?.filename || params?.content_id || 'file'}"\n\n` +
|
||||
'This is sample shared content. On a real node this would be the actual file.\n',
|
||||
'utf-8'
|
||||
).toString('base64')
|
||||
return res.json({ result: { data: placeholder, content_type: 'text/plain' } })
|
||||
}
|
||||
|
||||
case 'content.remove': {
|
||||
@@ -2192,7 +2339,6 @@ app.post('/rpc/v1', (req, res) => {
|
||||
return res.json({ result: { items: [] } })
|
||||
}
|
||||
case 'content.owned-get':
|
||||
case 'content.preview-peer':
|
||||
case 'content.download-peer-paid':
|
||||
case 'content.download-peer-invoice':
|
||||
case 'content.download-peer-onchain': {
|
||||
@@ -2236,6 +2382,23 @@ app.post('/rpc/v1', (req, res) => {
|
||||
case 'streaming.list-services': {
|
||||
return res.json({ result: { services: mockState.streamingServices || [] } })
|
||||
}
|
||||
case 'streaming.list-sessions': {
|
||||
const mkTime = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
|
||||
return res.json({ result: {
|
||||
sessions: [
|
||||
{ id: 'sess-demo-1', peer_id: 'npub1walker…k3u9', service_id: 'content-download', metric: 'bytes', allotment: 524_288_000, used: 231_211_008, paid_sats: 500, created_at: mkTime(134), last_topup_at: mkTime(22), expires_at: '', active: true },
|
||||
{ id: 'sess-demo-2', peer_id: 'npub1sailor…m2xq', service_id: 'nostr-relay', metric: 'milliseconds', allotment: 10_800_000, used: 4_920_000, paid_sats: 30, created_at: mkTime(82), last_topup_at: mkTime(82), expires_at: mkTime(-98), active: true },
|
||||
{ id: 'sess-demo-3', peer_id: 'npub1nomad…t7rd', service_id: 'content-download', metric: 'bytes', allotment: 104_857_600, used: 88_080_384, paid_sats: 100, created_at: mkTime(9), last_topup_at: mkTime(9), expires_at: '', active: true },
|
||||
],
|
||||
total_active: 3,
|
||||
total_revenue_sats: 770_000,
|
||||
revenue_by_service: {
|
||||
'content-download': 512_400,
|
||||
'nostr-relay': 201_600,
|
||||
'api-access': 56_000,
|
||||
},
|
||||
} })
|
||||
}
|
||||
case 'streaming.configure-service': {
|
||||
const p = params || {}
|
||||
const list = mockState.streamingServices || []
|
||||
@@ -2521,20 +2684,45 @@ app.post('/rpc/v1', (req, res) => {
|
||||
// Tor & Peer Networking
|
||||
// =========================================================================
|
||||
case 'tor.list-services': {
|
||||
mockState.torServices = mockState.torServices || defaultTorServices()
|
||||
return res.json({
|
||||
result: {
|
||||
tor_running: true,
|
||||
services: [
|
||||
{ name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false },
|
||||
{ name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false },
|
||||
{ name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false },
|
||||
{ name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false },
|
||||
{ name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false },
|
||||
],
|
||||
services: mockState.torServices,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'tor.create-service': {
|
||||
mockState.torServices = mockState.torServices || defaultTorServices()
|
||||
const name = params?.name
|
||||
if (!name) {
|
||||
return res.json({ error: { code: -1, message: 'Missing name' } })
|
||||
}
|
||||
if (mockState.torServices.some(s => s.name === name)) {
|
||||
return res.json({ error: { code: -1, message: `Service '${name}' already exists` } })
|
||||
}
|
||||
const stem = `${name.toLowerCase().replace(/[^a-z0-9]/g, '')}demo`
|
||||
const onion = (stem + 'x7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioadmnopqrstuv').slice(0, 56) + '.onion'
|
||||
const svc = { name, local_port: params?.local_port || 8080, onion_address: onion, enabled: true, unauthenticated: false, protocol: false }
|
||||
mockState.torServices = [...mockState.torServices, svc]
|
||||
console.log(`[Tor] Created hidden service: ${name} → ${onion}`)
|
||||
return res.json({ result: { created: true, name, onion_address: onion } })
|
||||
}
|
||||
|
||||
case 'tor.delete-service': {
|
||||
mockState.torServices = mockState.torServices || defaultTorServices()
|
||||
const name = params?.name
|
||||
if (name === 'archipelago') {
|
||||
return res.json({ error: { code: -1, message: "Cannot delete the node's own Tor service" } })
|
||||
}
|
||||
if (!mockState.torServices.some(s => s.name === name)) {
|
||||
return res.json({ error: { code: -1, message: `Service '${name}' not found` } })
|
||||
}
|
||||
mockState.torServices = mockState.torServices.filter(s => s.name !== name)
|
||||
return res.json({ result: { deleted: true, name } })
|
||||
}
|
||||
|
||||
case 'node.tor-address': {
|
||||
return res.json({
|
||||
result: {
|
||||
@@ -3395,13 +3583,32 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'wallet.networking-profits': {
|
||||
// Deterministic-but-varied week of profit events for the dashboard
|
||||
// chart (source/timestamp/sats mirror wallet::profits::ProfitEntry).
|
||||
const profitSources = ['streaming_revenue', 'content_sale', 'routing_fee']
|
||||
const profitNotes = ['Paid streaming session', 'Ecash content sale', 'Lightning routing fees']
|
||||
const recent = []
|
||||
const nowMs = Date.now()
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const daysAgo = (i * 3) % 7
|
||||
const hour = (i * 5) % 24
|
||||
recent.push({
|
||||
source: profitSources[i % 3],
|
||||
amount_sats: 800 + ((i * 7919) % 14000),
|
||||
timestamp: new Date(nowMs - daysAgo * 86_400_000 - hour * 3_600_000).toISOString(),
|
||||
description: profitNotes[i % 3],
|
||||
})
|
||||
}
|
||||
recent.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
return res.json({
|
||||
result: {
|
||||
total_sats: 5_231_978,
|
||||
content_sales_sats: 3_180_000,
|
||||
routing_fees_sats: 1_281_978,
|
||||
relay_sats: 770_000,
|
||||
streaming_revenue_sats: 770_000,
|
||||
recent,
|
||||
// legacy aliases kept for older UI builds
|
||||
relay_sats: 770_000,
|
||||
total_earned_sats: 5_231_978,
|
||||
total_forwarded_sats: 1_281_978,
|
||||
forward_count: 1284,
|
||||
@@ -5004,6 +5211,18 @@ const mockData = sessionBucketProxy('mockData')
|
||||
const walletState = sessionBucketProxy('walletState')
|
||||
const userState = sessionBucketProxy('userState')
|
||||
const mockState = sessionBucketProxy('mockState')
|
||||
|
||||
// Seed for the per-session Tor services demo state (tor.list-services /
|
||||
// tor.create-service / tor.delete-service round-trip against this).
|
||||
function defaultTorServices() {
|
||||
return [
|
||||
{ name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false },
|
||||
{ name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false },
|
||||
{ name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false },
|
||||
{ name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false },
|
||||
{ name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false },
|
||||
]
|
||||
}
|
||||
const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState')
|
||||
|
||||
// Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.100-alpha",
|
||||
"version": "1.7.101-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.100-alpha",
|
||||
"version": "1.7.101-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.100-alpha",
|
||||
"version": "1.7.101-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#7e22ce;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#231133;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#d8b4fe;stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- hooded figure, face in shadow -->
|
||||
<path d="M120 44 C86 44 72 76 72 106 L72 132 L168 132 L168 106 C168 76 154 44 120 44 Z" fill="url(#fig)"/>
|
||||
<ellipse cx="120" cy="104" rx="30" ry="34" fill="rgba(35,17,51,0.9)"/>
|
||||
<path d="M45 240 C45 182 78 150 120 150 C162 150 195 182 195 240 Z" fill="url(#fig)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1000 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#c2540a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b1c0a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:#fdba74;stop-opacity:0.55" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- head + suited shoulders -->
|
||||
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
|
||||
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
|
||||
<!-- collar + tie -->
|
||||
<path d="M104 156 L120 176 L136 156 L120 148 Z" fill="rgba(59,28,10,0.85)"/>
|
||||
<path d="M115 176 L125 176 L123 214 L120 220 L117 214 Z" fill="rgba(59,28,10,0.85)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:#93c5fd;stop-opacity:0.55" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- head + shoulders -->
|
||||
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
|
||||
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 860 B |
@@ -124,6 +124,13 @@ function syncKioskSafeArea() {
|
||||
if (typeof document === 'undefined') return
|
||||
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||
// Very first kiosk boot: the launcher opens /kiosk before the route
|
||||
// guard has had a chance to persist localStorage.kiosk.
|
||||
|| window.location.pathname === '/kiosk'
|
||||
// Global styling hook: kiosk chromium runs software-composited
|
||||
// (--in-process-gpu / --disable-gpu), where heavy compositing effects
|
||||
// (3D bg layers, animated mix-blend overlays) fail and paint black.
|
||||
document.documentElement.classList.toggle('kiosk-mode', isKiosk)
|
||||
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
|
||||
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
|
||||
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
|
||||
@@ -395,20 +402,51 @@ onMounted(async () => {
|
||||
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
||||
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
|
||||
// One-shot "Replay intro" request from the login screen — must survive the
|
||||
// auto-re-mark below (which otherwise instantly suppresses the replay on any
|
||||
// onboarded node).
|
||||
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
||||
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
||||
// The route object still holds the router's START_LOCATION ('/') here —
|
||||
// the initial navigation resolves asynchronously — so a deep-route load
|
||||
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
|
||||
// bar is the only truthful source this early.
|
||||
//
|
||||
// The kiosk chromium opens /kiosk — a marker route whose beforeEnter
|
||||
// stamps localStorage.kiosk and immediately redirects to '/'. That is a
|
||||
// root boot, not a deep route: without normalizing it, the fresh-install
|
||||
// kiosk never plays the typing intro (deep-route suppression regression).
|
||||
const rawBootPath = window.location.pathname
|
||||
const bootPath = rawBootPath === '/kiosk' ? '/' : rawBootPath
|
||||
// Public demo: every fresh boot at the root (first visit or a browser
|
||||
// refresh) starts with the typing splash for the full effect. In-session SPA
|
||||
// navigation never remounts App, and deep-route refreshes keep their place.
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO && bootPath === '/') replayRequested = true
|
||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
const splashCandidate = !seenIntro
|
||||
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||
|
||||
if (splashCandidate && onboardingComplete !== true) {
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
onboardingComplete = await checkOnboardingStatus()
|
||||
// Bound the pre-splash status check: its retry ladder can spend ~30s
|
||||
// against a still-booting backend, and this await holds the black
|
||||
// "!isReady" screen the whole time — exactly where the typing intro
|
||||
// should be playing on a fresh kiosk boot. Unknown within 2.5s → let
|
||||
// the splash play (a fresh install IS the slow-backend case; onboarded
|
||||
// nodes answer in milliseconds, so their suppression path is intact).
|
||||
// handleSplashComplete re-checks with full retries after the intro.
|
||||
onboardingComplete = await Promise.race([
|
||||
checkOnboardingStatus(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
|
||||
])
|
||||
} catch {
|
||||
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
}
|
||||
}
|
||||
|
||||
if (!seenIntro && onboardingComplete === true) {
|
||||
if (!replayRequested && !seenIntro && onboardingComplete === true) {
|
||||
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
|
||||
seenIntro = true
|
||||
}
|
||||
@@ -417,11 +455,30 @@ onMounted(async () => {
|
||||
|
||||
if (shouldShowIntroSplash({
|
||||
seenIntro,
|
||||
routePath: route.path,
|
||||
routePath: bootPath,
|
||||
fromBoot,
|
||||
devMode: import.meta.env.VITE_DEV_MODE,
|
||||
onboardingComplete,
|
||||
replayRequested,
|
||||
})) {
|
||||
// The intro is definitely playing — unmute the whole cinematic (speech,
|
||||
// synthwave, pops) even on browsers whose localStorage says onboarding is
|
||||
// complete; the sound gate otherwise silences replays.
|
||||
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
|
||||
enableCinematicSounds()
|
||||
// Kick off the intro video download NOW: the splash's <video> element only
|
||||
// mounts ~20s in (after the typing sequence), and on a cold cache the file
|
||||
// would otherwise start fetching mid-sequence and stutter. A detached
|
||||
// <video preload=auto> is used because Chromium does not support
|
||||
// <link rel=preload as=video> — the media cache then serves the splash's
|
||||
// element, which uses the identical URL.
|
||||
try {
|
||||
const warm = document.createElement('video')
|
||||
warm.muted = true
|
||||
warm.preload = 'auto'
|
||||
warm.src = '/assets/video/video-intro.mp4?v=8'
|
||||
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
|
||||
} catch { /* ignore */ }
|
||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||
showSplash.value = true
|
||||
} else {
|
||||
@@ -483,42 +540,61 @@ function onShareToMeshMessage(ev: MessageEvent) {
|
||||
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
||||
*/
|
||||
async function handleSplashComplete() {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
sessionStorage.setItem('archipelago_from_splash', '1')
|
||||
|
||||
// Commit the destination route BEFORE revealing RouterView. Revealing
|
||||
// first rendered whatever route was current — '/' → RootRedirect's
|
||||
// spinner — for a beat between the intro's final frame and the
|
||||
// onboarding/login screen actually mounting: a visible flash on every
|
||||
// fresh install. Await the push so the first RouterView render is
|
||||
// already the destination.
|
||||
const reveal = () => {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.push('/login').catch(() => {})
|
||||
await router.push('/login').catch(() => {})
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
|
||||
// Demo: the cinematic always continues into the onboarding intro page —
|
||||
// the mock backend reports "onboarded", which would otherwise route
|
||||
// straight to /login and cut the sequence short.
|
||||
{
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO) {
|
||||
await router.push('/onboarding/intro').catch(() => {})
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
const seenOnboarding = await checkOnboardingStatus()
|
||||
if (seenOnboarding === true) {
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
if (seenOnboarding === false) {
|
||||
router.push('/onboarding/intro').catch(() => {})
|
||||
return
|
||||
}
|
||||
// Backend unreachable after retries. Prefer the localStorage
|
||||
// cache on THIS browser (if a prior successful check set it) —
|
||||
// otherwise defer to RootRedirect which polls + retries rather
|
||||
// than forcing an already-onboarded user through the wizard.
|
||||
if (localStorage.getItem('neode_onboarding_complete') === '1') {
|
||||
router.push('/login').catch(() => {})
|
||||
await router.push('/login').catch(() => {})
|
||||
} else if (seenOnboarding === false) {
|
||||
await router.push('/onboarding/intro').catch(() => {})
|
||||
} else if (localStorage.getItem('neode_onboarding_complete') === '1') {
|
||||
// Backend unreachable after retries. Prefer the localStorage
|
||||
// cache on THIS browser (if a prior successful check set it) —
|
||||
// otherwise defer to RootRedirect which polls + retries rather
|
||||
// than forcing an already-onboarded user through the wizard.
|
||||
await router.push('/login').catch(() => {})
|
||||
} else {
|
||||
router.push('/').catch(() => {})
|
||||
await router.push('/').catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
// Do NOT default to /onboarding/intro here. RootRedirect has retry
|
||||
// + polling + boot-screen handling; let it decide.
|
||||
router.push('/').catch(() => {})
|
||||
await router.push('/').catch(() => {})
|
||||
}
|
||||
reveal()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -8,66 +8,240 @@
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors z-10"
|
||||
aria-label="Close companion modal"
|
||||
@click="dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Transition :name="slideName" mode="out-in">
|
||||
<!-- Screen 1: get the app -->
|
||||
<div v-if="step === 'download'" key="download">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download companion app
|
||||
</a>
|
||||
<div class="hidden md:flex justify-center">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="Companion app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
<div class="hidden md:flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="Companion app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
Download app
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="advanceFromDownload"
|
||||
>
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download companion app
|
||||
</a>
|
||||
<!-- Screen 2 (remote access): install WireGuard -->
|
||||
<div v-else-if="step === 'wireguard'" key="wireguard">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="wgApkQrDataUrl"
|
||||
:src="wgApkQrDataUrl"
|
||||
alt="WireGuard app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="wireguardDownloadUrl"
|
||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
Download WireGuard
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showWgQrScreen"
|
||||
>
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full mt-2 py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showDownloadScreen"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
|
||||
<div v-else-if="step === 'wgqr'" key="wgqr">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-3">
|
||||
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="wgQrDataUrl"
|
||||
:src="wgQrDataUrl"
|
||||
alt="WireGuard tunnel config QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
|
||||
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
|
||||
|
||||
<!-- Same-device path: a phone can't scan its own screen, so offer
|
||||
the config as a file WireGuard can import. -->
|
||||
<button
|
||||
v-if="wgConfig"
|
||||
type="button"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
|
||||
@click="downloadWgConfig"
|
||||
>
|
||||
Download tunnel config
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors mb-2"
|
||||
@click="showPairScreen"
|
||||
>
|
||||
I'm connected
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showWireguardScreen('back')"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Screen 2: pair the app with this node -->
|
||||
<div v-else key="pair">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<path d="M13 13h3v3h-3zM17 17h3v3h-3z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
In the companion app, choose “Scan Node's QR” and point your phone here — the server details fill in automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<!-- Bigger than the download QR: this one is scanned by the
|
||||
companion app camera, and physical size drives decode. -->
|
||||
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="pairQrDataUrl"
|
||||
:src="pairQrDataUrl"
|
||||
alt="Companion app pairing QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Same-device path: a QR on the phone's own screen can't be
|
||||
scanned, so offer the deep link directly on small screens. -->
|
||||
<a
|
||||
v-if="pairingUrl"
|
||||
:href="pairingUrl"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors mb-2"
|
||||
>
|
||||
Open in companion app
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="backFromPair"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -75,9 +249,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
@@ -89,36 +266,260 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
|
||||
// Deep-link scheme the companion app registers; carries the server entry the
|
||||
// app should create (see docs/companion-pairing-qr.md for the contract).
|
||||
const PAIR_SCHEME = 'archipelago://pair'
|
||||
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
|
||||
|
||||
// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps
|
||||
// between "I've installed it" and the pairing QR. Real nodes pair against the
|
||||
// node's VPN address instead of the LAN origin; the demo walks the same steps
|
||||
// with a mock tunnel config (its pairing QR still carries the public demo
|
||||
// URL). Set to false to restore the original two-screen flow (instant revert).
|
||||
const REMOTE_ACCESS_STEPS = true
|
||||
|
||||
// The WireGuard peer config only routes the VPN subnet (AllowedIPs
|
||||
// 10.44.0.0/16 — core/archipelago/src/api/rpc/vpn.rs), so away from the LAN
|
||||
// the node is reachable exclusively at its tunnel address. The pairing QR
|
||||
// must therefore advertise this, not the browser's LAN origin. The flow has
|
||||
// the user switch the tunnel on right before pairing, so it validates.
|
||||
const VPN_NODE_URL = 'http://10.44.0.1'
|
||||
|
||||
// Official WireGuard Android app (com.wireguard.android 1.0.20260315,
|
||||
// sha256 f92971bc…, mirrored from download.wireguard.com), served like the
|
||||
// companion APK: gitea raw-on-main for nodes, own origin for the demo.
|
||||
const WIREGUARD_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/wireguard.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk'
|
||||
|
||||
// Name of the auto-created VPN peer for the phone running the companion.
|
||||
const WG_PEER_NAME = 'companion-phone'
|
||||
|
||||
const visible = ref(false)
|
||||
const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download')
|
||||
const slideName = ref('slide-forward')
|
||||
const qrDataUrl = ref('')
|
||||
const pairQrDataUrl = ref('')
|
||||
const pairingUrl = ref('')
|
||||
const wgApkQrDataUrl = ref('')
|
||||
const wgQrDataUrl = ref('')
|
||||
const wgConfig = ref('')
|
||||
const wgLoading = ref(false)
|
||||
const wgError = ref('')
|
||||
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||
const wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL
|
||||
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
// Base delay before the popup may appear, and extra breathing room after the
|
||||
// dashboard entrance cinematic ends so the popup never cuts into the reveal.
|
||||
const BASE_DELAY_MS = 5000
|
||||
const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== '1') {
|
||||
// Delay slightly so it doesn't compete with login animation
|
||||
setTimeout(() => { visible.value = true }, 5000)
|
||||
setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
})
|
||||
|
||||
function maybeShow() {
|
||||
// Show only after the scene has been CONTINUOUSLY calm (no reveal
|
||||
// cinematic) for the full grace window. The previous point-in-time check
|
||||
// raced a slow-starting reveal — on a cold cache the entrance video can
|
||||
// begin buffering after the 5s base delay, so the flag was still false
|
||||
// when sampled and the popup cut straight into the cinematic.
|
||||
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
|
||||
calmTicker = setInterval(() => {
|
||||
if (loginTransition.introCinematicPlaying) {
|
||||
calmSince = null
|
||||
return
|
||||
}
|
||||
if (calmSince === null) calmSince = Date.now()
|
||||
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
visible.value = true
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||
watch(companionIntroRequested, (requested) => {
|
||||
if (!requested) return
|
||||
companionIntroRequested.value = false
|
||||
if (calmTicker) {
|
||||
clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
}
|
||||
step.value = 'download'
|
||||
visible.value = true
|
||||
})
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (!isVisible) return
|
||||
// Generate large and let CSS scale down — at 112px source a ~45-module QR
|
||||
// is 2.5px/module, which camera decoders (the companion app included)
|
||||
// routinely fail on. 512px keeps every module crisp.
|
||||
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
|
||||
width: 112,
|
||||
margin: 1,
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) {
|
||||
wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, {
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
/**
|
||||
* The server URL the companion app should connect to. The demo advertises its
|
||||
* public https origin; a real node advertises whatever address the browser is
|
||||
* already using — except on the kiosk, where that is localhost and useless to
|
||||
* a phone, so fall back to the node's mDNS .local name.
|
||||
*/
|
||||
async function resolveServerUrl(): Promise<string> {
|
||||
if (IS_DEMO) return DEMO_SERVER_URL
|
||||
// Remote-access flow: the app must connect through the tunnel the user just
|
||||
// switched on — the LAN origin is unreachable from outside AllowedIPs.
|
||||
if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL
|
||||
const { hostname, origin } = window.location
|
||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
||||
try {
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
|
||||
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
|
||||
} catch {
|
||||
// RPC unavailable — fall through to the (localhost) origin
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
async function buildPairingUrl(): Promise<string> {
|
||||
const params = new URLSearchParams({ v: '1', url: await resolveServerUrl() })
|
||||
// Only the shared demo password ever rides in the QR; real node passwords
|
||||
// are never available to the frontend and stay typed-by-hand in the app.
|
||||
if (IS_DEMO) params.set('pw', DEMO_PASSWORD)
|
||||
return `${PAIR_SCHEME}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function showPairScreen() {
|
||||
slideName.value = 'slide-forward'
|
||||
step.value = 'pair'
|
||||
if (!pairQrDataUrl.value) {
|
||||
pairingUrl.value = await buildPairingUrl()
|
||||
// Large source + a real quiet zone; this QR is scanned by the companion
|
||||
// app's camera, so give it every advantage (see download QR note above).
|
||||
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function showDownloadScreen() {
|
||||
slideName.value = 'slide-back'
|
||||
step.value = 'download'
|
||||
}
|
||||
|
||||
function advanceFromDownload() {
|
||||
if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward')
|
||||
else void showPairScreen()
|
||||
}
|
||||
|
||||
function showWireguardScreen(direction: 'forward' | 'back' = 'forward') {
|
||||
slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back'
|
||||
step.value = 'wireguard'
|
||||
}
|
||||
|
||||
function showWgQrScreen() {
|
||||
slideName.value = 'slide-forward'
|
||||
step.value = 'wgqr'
|
||||
void loadWgPeer()
|
||||
}
|
||||
|
||||
function backFromPair() {
|
||||
if (REMOTE_ACCESS_STEPS) {
|
||||
slideName.value = 'slide-back'
|
||||
step.value = 'wgqr'
|
||||
} else {
|
||||
showDownloadScreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Create (or fetch) the phone's VPN peer and render its config as a QR.
|
||||
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
|
||||
// looked up first so reopening the modal never duplicates it.
|
||||
async function loadWgPeer() {
|
||||
if (wgQrDataUrl.value || wgLoading.value) return
|
||||
wgLoading.value = true
|
||||
wgError.value = ''
|
||||
try {
|
||||
const listed = await rpcClient
|
||||
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||
.catch(() => ({ peers: [] as { name: string }[] }))
|
||||
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
|
||||
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
|
||||
params: { name: WG_PEER_NAME },
|
||||
})
|
||||
wgConfig.value = res.config
|
||||
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
|
||||
} finally {
|
||||
wgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Same-device path: hand the config to the WireGuard app as an importable
|
||||
// .conf file (a phone can't scan its own screen).
|
||||
function downloadWgConfig() {
|
||||
if (!wgConfig.value) return
|
||||
const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago.conf'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
step.value = 'download'
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, '1')
|
||||
} catch {
|
||||
@@ -134,4 +535,14 @@ function dismiss() {
|
||||
.overlay-fade-leave-to { opacity: 0; }
|
||||
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
|
||||
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
|
||||
|
||||
/* Horizontal slide between the download and pairing screens */
|
||||
.slide-forward-enter-active,
|
||||
.slide-forward-leave-active,
|
||||
.slide-back-enter-active,
|
||||
.slide-back-leave-active { transition: transform 0.25s ease, opacity 0.25s ease; }
|
||||
.slide-forward-enter-from { transform: translateX(40px); opacity: 0; }
|
||||
.slide-forward-leave-to { transform: translateX(-40px); opacity: 0; }
|
||||
.slide-back-enter-from { transform: translateX(-40px); opacity: 0; }
|
||||
.slide-back-leave-to { transform: translateX(40px); opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -101,7 +101,10 @@ const show = computed(() =>
|
||||
)
|
||||
|
||||
function backUpNow() {
|
||||
router.push(LND_DETAILS_PATH).catch(() => {})
|
||||
// seed-backup=1 makes the LND page open the reveal flow immediately —
|
||||
// landing on the app page with the card below the fold read as
|
||||
// "clicking the notification did nothing".
|
||||
router.push({ path: LND_DETAILS_PATH, query: { 'seed-backup': '1' } }).catch(() => {})
|
||||
}
|
||||
|
||||
function snooze() {
|
||||
|
||||
@@ -25,16 +25,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
|
||||
const showUpdatePrompt = ref(false)
|
||||
let updateCallback: (() => Promise<void>) | null = null
|
||||
|
||||
// Reload for a genuine SW update — but never mid-cinematic. The splash
|
||||
// typing intro and the dashboard-reveal cinematic are the two moments a
|
||||
// surprise reload reads as "the app just reset itself" (kiosk and demo
|
||||
// replay the intro on every boot/visit, so an update landing during it
|
||||
// restarted the whole sequence). Wait until both are over, then reload.
|
||||
function reloadAfterCinematic() {
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const calm = () =>
|
||||
document.body.classList.contains('splash-complete') &&
|
||||
!loginTransition.introCinematicPlaying
|
||||
if (calm()) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
const poll = setInterval(() => {
|
||||
if (calm()) {
|
||||
clearInterval(poll)
|
||||
window.location.reload()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Listen for service worker updates
|
||||
if ('serviceWorker' in navigator) {
|
||||
// On the very first visit the page loads with no controlling SW; the
|
||||
// freshly-installed worker then activates and claims the page
|
||||
// (autoUpdate → clientsClaim), firing controllerchange. Reloading on
|
||||
// that first claim caused the fresh-install "loads, then reloads"
|
||||
// jank (worst on kiosk). Only reload when an existing controller is
|
||||
// REPLACED — i.e. a genuine update.
|
||||
let hadController = !!navigator.serviceWorker.controller
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
// Service worker updated, reload the page
|
||||
window.location.reload()
|
||||
if (!hadController) {
|
||||
hadController = true
|
||||
return
|
||||
}
|
||||
reloadAfterCinematic()
|
||||
})
|
||||
|
||||
// Check for updates periodically
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
preload="auto"
|
||||
poster="/assets/img/bg-intro.jpg"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
|
||||
<!-- Fallback to image if video fails -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
@@ -266,16 +266,12 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Check if user has seen intro
|
||||
// Also detect returning users who cleared cache: if we're on a /dashboard route,
|
||||
// the backend session is still active so the user has been here before.
|
||||
const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isOnDashboard = window.location.pathname.startsWith('/dashboard')
|
||||
const seenIntro = storedSeenIntro || isOnDashboard
|
||||
// Persist the flag so subsequent loads don't re-check
|
||||
if (!storedSeenIntro && isOnDashboard) {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
}
|
||||
// NOTE: whether the intro should play is decided ONCE by App.vue
|
||||
// (shouldShowIntroSplash) — this component is only mounted when the answer was
|
||||
// yes, so it must not re-check neode_intro_seen itself. The old internal
|
||||
// re-check silently skipped the whole sequence for any browser that had seen
|
||||
// the intro before, even when App.vue explicitly asked for a replay (demo
|
||||
// fresh visits, the Replay Intro link).
|
||||
|
||||
function handleEnterKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
|
||||
@@ -468,13 +464,7 @@ function startAlienIntro() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (seenIntro) {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
emit('complete')
|
||||
} else {
|
||||
window.addEventListener('keydown', handleEnterKey)
|
||||
}
|
||||
window.addEventListener('keydown', handleEnterKey)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -103,6 +103,7 @@ const emit = defineEmits<{
|
||||
delete: [path: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
preview: [path: string]
|
||||
play: [path: string, name: string]
|
||||
}>()
|
||||
|
||||
const cloudStore = useCloudStore()
|
||||
@@ -110,7 +111,7 @@ const imgFailed = ref(false)
|
||||
|
||||
const ext = computed(() => props.item.extension)
|
||||
const isDir = computed(() => props.item.isDir)
|
||||
const { isImage, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
const { isImage, isVideo, isAudio, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (!isImage.value || imgFailed.value) return null
|
||||
@@ -122,8 +123,14 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
function handleClick() {
|
||||
if (props.item.isDir) {
|
||||
emit('navigate', props.item.path)
|
||||
} else if (isAudio.value) {
|
||||
// Music goes to the global bottom-bar player, not the lightbox.
|
||||
emit('play', props.item.path, props.item.name)
|
||||
} else if (isImage.value || isVideo.value) {
|
||||
emit('preview', props.item.path)
|
||||
} else {
|
||||
// Non-media files open by downloading — a click should always act on the file.
|
||||
window.location.href = downloadHref.value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
:item="item"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@play="(path, name) => $emit('play', path, name)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
@preview="$emit('preview', $event)"
|
||||
/>
|
||||
|
||||
@@ -216,7 +216,8 @@ async function connect() {
|
||||
device_kind: form.value.deviceKind,
|
||||
})
|
||||
mesh.dismissDetectedDevice(path)
|
||||
void router.push('/mesh')
|
||||
// The Mesh view lives under the dashboard shell — a bare /mesh 404s.
|
||||
void router.push('/dashboard/mesh')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||
* bypasses the once-per-browser auto-show gate.
|
||||
*/
|
||||
export const companionIntroRequested = ref(false)
|
||||
|
||||
export function openCompanionIntro(): void {
|
||||
companionIntroRequested.value = true
|
||||
}
|
||||
@@ -199,6 +199,22 @@ function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// ─── Nav-Ring Modality ──────────────────────────────────────────
|
||||
// The orange focus ring must only render while the user is actually
|
||||
// navigating directionally (arrows / gamepad). Ring visibility is gated in
|
||||
// CSS behind html.controller-nav; directional input turns it on, any
|
||||
// pointer input (tap, click) turns it off. Without this gate the ring
|
||||
// painted on plain :focus — every tap on a tabindex card, and every
|
||||
// programmatic route-change autofocus, lit it up with no controller in
|
||||
// sight (worst on mobile).
|
||||
function setNavRing(on: boolean) {
|
||||
document.documentElement.classList.toggle('controller-nav', on)
|
||||
}
|
||||
|
||||
function navRingActive(): boolean {
|
||||
return document.documentElement.classList.contains('controller-nav')
|
||||
}
|
||||
|
||||
// ─── Main Composable ────────────────────────────────────────────
|
||||
|
||||
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
@@ -396,9 +412,18 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
|
||||
// Mark controller as active
|
||||
isControllerActive.value = true
|
||||
setNavRing(true)
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
|
||||
|
||||
// Nothing focused yet (fresh page, or focus was lost) — enter nav mode
|
||||
// on the first arrow press by focusing the first container, instead of
|
||||
// spatially navigating from <body>.
|
||||
if (!activeEl || activeEl === document.body || activeEl === document.documentElement) {
|
||||
autoFocusMain()
|
||||
return
|
||||
}
|
||||
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' as const
|
||||
: e.key === 'ArrowRight' ? 'right' as const
|
||||
: e.key === 'ArrowUp' ? 'up' as const
|
||||
@@ -611,6 +636,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
|
||||
isControllerActive.value = true
|
||||
setNavRing(true)
|
||||
}
|
||||
|
||||
function handleGamepadDisconnected() {
|
||||
@@ -644,6 +670,10 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
// ─── Auto-Focus on Route Change ────────────────────────────
|
||||
|
||||
function autoFocusMain() {
|
||||
// Only when the user is actually in directional-nav mode. For pointer
|
||||
// and touch users this programmatic focus painted the first card's
|
||||
// focus ring on every route change with zero controller input.
|
||||
if (!navRingActive()) return
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
// Don't steal focus from inputs, modals, or sidebar
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
|
||||
@@ -668,9 +698,16 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────────
|
||||
|
||||
// Any pointer interaction ends directional-nav mode — the ring should
|
||||
// never linger once the user reaches for mouse or touch.
|
||||
function handlePointerDown() {
|
||||
setNavRing(false)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkGamepads()
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('pointerdown', handlePointerDown, { capture: true, passive: true })
|
||||
window.addEventListener('wheel', handleWheel, { passive: false })
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
@@ -680,11 +717,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('pointerdown', handlePointerDown, { capture: true } as EventListenerOptions)
|
||||
window.removeEventListener('wheel', handleWheel)
|
||||
window.removeEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
if (pollIntervalId) clearInterval(pollIntervalId)
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
setNavRing(false)
|
||||
})
|
||||
|
||||
return { isControllerActive, gamepadCount }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Public-demo helpers.
|
||||
*
|
||||
* The demo build (VITE_DEMO=1) replays the intro/onboarding on each visit, but
|
||||
* only once per calendar day per browser — tracked in localStorage so it
|
||||
* survives the short-lived backend session. Also exposes the shared demo
|
||||
* credentials shown on the login screen.
|
||||
* The demo build (VITE_DEMO=1) replays the typing splash + intro/onboarding on
|
||||
* EVERY fresh boot of the app at '/' (first visit or browser refresh) — see
|
||||
* App.vue and RootRedirect.demoRoute. Also exposes the shared demo credentials
|
||||
* shown on the login screen.
|
||||
*/
|
||||
|
||||
export const IS_DEMO =
|
||||
@@ -13,36 +13,10 @@ export const IS_DEMO =
|
||||
/** Memorable shared password for the public demo (must match the mock backend). */
|
||||
export const DEMO_PASSWORD = 'entertoexit'
|
||||
|
||||
const INTRO_DATE_KEY = 'demo_intro_date'
|
||||
|
||||
function todayKey(): string {
|
||||
// Local calendar day, e.g. "2026-06-22".
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** True if this browser already watched the intro earlier today. */
|
||||
export function demoIntroSeenToday(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(INTRO_DATE_KEY) === todayKey()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that the intro has been seen today, so it won't replay until tomorrow. */
|
||||
export function markDemoIntroSeen(): void {
|
||||
try {
|
||||
localStorage.setItem(INTRO_DATE_KEY, todayKey())
|
||||
} catch {
|
||||
/* ignore (private mode / storage disabled) */
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget today's "seen" marker so the intro plays again (e.g. "Replay Intro"). */
|
||||
/** Forget any legacy per-day gate marker (pre-2026-07 builds stored one). */
|
||||
export function clearDemoIntroSeen(): void {
|
||||
try {
|
||||
localStorage.removeItem(INTRO_DATE_KEY)
|
||||
localStorage.removeItem('demo_intro_date')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -8,14 +8,27 @@
|
||||
* exempt and continue to play regardless.
|
||||
*/
|
||||
|
||||
/** One-way, per-page-load override: when App.vue decides to run the intro
|
||||
* splash (demo fresh boot, prod first install, Replay Intro), the WHOLE
|
||||
* cinematic — speech, synthwave, pops — must be audible even though
|
||||
* localStorage already says onboarding is complete. Without this the
|
||||
* replayed intro runs silent (lost "Welcome Noderunner" + song). */
|
||||
let cinematicMode = false
|
||||
export function enableCinematicSounds() {
|
||||
cinematicMode = true
|
||||
}
|
||||
|
||||
/** True when the node has not yet completed onboarding — i.e. we're
|
||||
* still in the first-install cinematic. Reads the localStorage cache
|
||||
* set by useOnboarding (which is re-seeded from the backend on each
|
||||
* successful check), so this stays correct after a browser clear
|
||||
* once the onboarding-complete probe runs. Sound calls that fire
|
||||
* before that probe completes will fall through silent on an already-
|
||||
* onboarded node — which is exactly what we want. */
|
||||
* still in the first-install cinematic — or when the intro is being
|
||||
* deliberately replayed this page load (see enableCinematicSounds).
|
||||
* Reads the localStorage cache set by useOnboarding (which is
|
||||
* re-seeded from the backend on each successful check), so this stays
|
||||
* correct after a browser clear once the onboarding-complete probe
|
||||
* runs. Sound calls that fire before that probe completes will fall
|
||||
* through silent on an already-onboarded node — which is exactly what
|
||||
* we want: ordinary re-logins stay quiet. */
|
||||
function isFirstInstallPhase(): boolean {
|
||||
if (cinematicMode) return true
|
||||
try {
|
||||
return localStorage.getItem('neode_onboarding_complete') !== '1'
|
||||
} catch {
|
||||
@@ -251,9 +264,19 @@ export function playKeyboardTypingSound() {
|
||||
}
|
||||
|
||||
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||||
export function playDashboardLoadOomph() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const ctx = getContext()
|
||||
/**
|
||||
* @param force Play regardless of install phase. The first dashboard entry
|
||||
* right after the onboarding wizard needs this: by then onboarding is already
|
||||
* flagged complete in localStorage, so the isFirstInstallPhase() gate (which
|
||||
* exists to keep ordinary re-logins quiet) would silence the one entrance
|
||||
* that SHOULD be loud.
|
||||
*/
|
||||
export function playDashboardLoadOomph(force = false) {
|
||||
if (!force && !isFirstInstallPhase()) return
|
||||
// The login→dashboard route change runs stopAllAudio(), which CLOSES the
|
||||
// AudioContext — so the context must be recreated here, not just fetched.
|
||||
// The login click's sticky user activation lets the fresh context run.
|
||||
const ctx = ensureContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,12 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
const pendingWelcomeTyping = ref(false)
|
||||
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
|
||||
const startWelcomeTyping = ref(false)
|
||||
/**
|
||||
* True while the dashboard entrance cinematic (zoom/glitch reveal) is
|
||||
* playing. Overlays that would visually compete with it (e.g. the
|
||||
* companion intro popup) hold off until this flips back to false.
|
||||
*/
|
||||
const introCinematicPlaying = ref(false)
|
||||
|
||||
function setJustLoggedIn(value: boolean) {
|
||||
justLoggedIn.value = value
|
||||
@@ -32,6 +38,10 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
startWelcomeTyping.value = value
|
||||
}
|
||||
|
||||
function setIntroCinematicPlaying(value: boolean) {
|
||||
introCinematicPlaying.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
justLoggedIn,
|
||||
setJustLoggedIn,
|
||||
@@ -41,5 +51,7 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
setPendingWelcomeTyping,
|
||||
startWelcomeTyping,
|
||||
setStartWelcomeTyping,
|
||||
introCinematicPlaying,
|
||||
setIntroCinematicPlaying,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,7 +79,9 @@ select:focus-visible {
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
|
||||
/* Card action placement: keep compact header buttons for genuinely wide layouts. */
|
||||
/* Card action placement: actions always live at the bottom of the card as
|
||||
full-width buttons — same layout on every screen size. The compact header
|
||||
variants are permanently retired for consistency (desktop == mobile). */
|
||||
.responsive-card-actions-top,
|
||||
.web5-card-actions-top {
|
||||
display: none;
|
||||
@@ -109,20 +111,6 @@ select:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.responsive-card-actions-top,
|
||||
.web5-card-actions-top {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.responsive-card-actions-bottom,
|
||||
.responsive-card-actions-bottom-grid,
|
||||
.web5-card-actions-bottom,
|
||||
.web5-card-actions-bottom-grid {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
|
||||
@media (max-width: 767px) {
|
||||
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
|
||||
@@ -270,9 +258,13 @@ input[type="radio"]:active + * {
|
||||
|
||||
/* Containers: console-style focus — lift + ambient orange glow.
|
||||
Pure glow approach — no border-color or outline changes, avoids
|
||||
Chromium compositor bugs with border-radius on translateZ(0) layers. */
|
||||
[data-controller-container]:focus-visible,
|
||||
[data-controller-container]:focus {
|
||||
Chromium compositor bugs with border-radius on translateZ(0) layers.
|
||||
Gated behind html.controller-nav (set by useControllerNav only while
|
||||
the user navigates with arrows/gamepad; cleared on any pointer input).
|
||||
Ungated, the plain :focus selector painted the ring on every tap of a
|
||||
tabindex card — the "gamepad selection with no gamepad" bug on mobile. */
|
||||
html.controller-nav [data-controller-container]:focus-visible,
|
||||
html.controller-nav [data-controller-container]:focus {
|
||||
outline: none;
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
box-shadow:
|
||||
@@ -1615,6 +1607,18 @@ html.no-backdrop *::after {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* Kiosk: the infinite mix-blend-mode:multiply glitch overlays with
|
||||
background-attachment:fixed are a known black-screen vector under the
|
||||
kiosk's software compositor — a missed repaint leaves a dark multiply
|
||||
layer stuck over the whole viewport. Their base opacity is 0, so
|
||||
disabling the animations hides them entirely on kiosk. */
|
||||
html.kiosk-mode body::before,
|
||||
html.kiosk-mode body::after,
|
||||
html.kiosk-mode::before {
|
||||
animation: none !important;
|
||||
will-change: auto !important;
|
||||
}
|
||||
|
||||
/* Dashboard: full viewport width, no letterboxing, no body scroll */
|
||||
body.dashboard-active {
|
||||
overflow: hidden;
|
||||
@@ -1715,26 +1719,28 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
||||
.cloud-file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cloud-file-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
transition: background-color 0.2s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
/* Readable card rows (like the Peer Files list) — bare text over the
|
||||
wallpaper was unreadable. */
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
align-items: center;
|
||||
}
|
||||
.cloud-file-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.cloud-file-item:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@@ -2087,6 +2093,7 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
||||
|
||||
.mobile-category-pill {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
|
||||
@@ -28,4 +28,14 @@ describe('shouldShowIntroSplash', () => {
|
||||
onboardingComplete: null,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('an explicit replay request overrides every suppression rule', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: true,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: true,
|
||||
replayRequested: true,
|
||||
})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,9 +4,12 @@ export interface IntroSplashDecisionInput {
|
||||
fromBoot: boolean
|
||||
devMode?: string
|
||||
onboardingComplete: boolean | null
|
||||
/** Explicit "Replay intro" click — overrides every suppression rule. */
|
||||
replayRequested?: boolean
|
||||
}
|
||||
|
||||
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
|
||||
if (input.replayRequested) return true
|
||||
if (input.seenIntro) return false
|
||||
if (input.onboardingComplete === true) return false
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
>{{ tab.name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<div v-show="showCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="category in CATEGORIES"
|
||||
:key="category.id"
|
||||
@@ -37,21 +37,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: pill strips (NOT the fixed top tabs — those stay page-level
|
||||
navigation) + search below. .mobile-category-strip also opts these
|
||||
rows out of the dashboard's swipe-to-switch-page gesture. -->
|
||||
<!-- Mobile: full-width tab switcher (distinct from the category pills),
|
||||
category pill strip, then search. .mobile-category-strip opts the
|
||||
pills out of the dashboard's swipe-to-switch-page gesture. -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="mobile-category-strip mb-2" aria-label="Cloud tabs">
|
||||
<div class="cloud-tab-switcher cloud-tab-switcher-full mb-3">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': activeTab === tab.id }"
|
||||
class="cloud-tab-btn"
|
||||
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }"
|
||||
type="button"
|
||||
>{{ tab.name }}</button>
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="File categories">
|
||||
<div v-if="showCategories" class="mobile-category-strip mb-3" aria-label="File categories">
|
||||
<button
|
||||
v-for="category in CATEGORIES"
|
||||
:key="category.id"
|
||||
@@ -85,31 +85,69 @@
|
||||
<div v-if="filteredSearchResults.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
No files match “{{ searchQuery }}”.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<button
|
||||
v-for="r in filteredSearchResults"
|
||||
:key="r.key"
|
||||
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
|
||||
@click="openSearchResult(r)"
|
||||
>
|
||||
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(r.category).iconBg">
|
||||
<svg class="w-5 h-5" :class="categoryMeta(r.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(p, i) in categoryMeta(r.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm text-white truncate">{{ r.name }}</span>
|
||||
<span class="block text-[11px] text-white/40 truncate">{{ r.detail }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-[10px] px-2 py-0.5 rounded-full shrink-0"
|
||||
:class="r.source === 'mine' ? 'bg-blue-500/15 text-blue-400' : 'bg-purple-500/15 text-purple-400'"
|
||||
>{{ r.source === 'mine' ? 'My Files' : r.peerName }}</span>
|
||||
</button>
|
||||
<div v-else class="cloud-file-list">
|
||||
<template v-for="r in filteredSearchResults" :key="r.key">
|
||||
<!-- Own files act like real file rows: actions + click opens the file -->
|
||||
<FileCard
|
||||
v-if="r.item"
|
||||
:item="r.item"
|
||||
@delete="handleDelete"
|
||||
@share="handleShare"
|
||||
@play="handlePlay"
|
||||
@preview="(p: string) => handlePreview(p, searchMineItems)"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
|
||||
@click="router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })"
|
||||
>
|
||||
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(r.category).iconBg">
|
||||
<svg class="w-5 h-5" :class="categoryMeta(r.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(p, i) in categoryMeta(r.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm text-white truncate">{{ r.name }}</span>
|
||||
<span class="block text-[11px] text-white/40 truncate">{{ r.detail }}</span>
|
||||
</span>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ r.peerName }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ My Files — every own file, flat, with real file actions ═════════════ -->
|
||||
<div v-else-if="activeTab === 'mine'">
|
||||
<div v-if="myFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||
<svg class="animate-spin h-4 w-4" 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>
|
||||
Loading your files…
|
||||
</div>
|
||||
<div v-else-if="!fileBrowserRunning" class="glass-card p-8 text-center">
|
||||
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||
Open App Store
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="filteredMyFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
{{ selectedCategory === 'all' ? 'No files yet — upload some from the Folders tab.' : 'No files in this category.' }}
|
||||
</div>
|
||||
<div v-else class="cloud-file-list">
|
||||
<FileCard
|
||||
v-for="item in filteredMyFiles"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
@delete="handleDelete"
|
||||
@share="handleShare"
|
||||
@play="handlePlay"
|
||||
@preview="(p: string) => handlePreview(p, filteredMyFiles)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Peer Files tab — every file shared by every peer ═════════════ -->
|
||||
<div v-else-if="activeTab === 'peers'">
|
||||
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||
@@ -154,10 +192,10 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ All Files / My Files — section (+ peer) cards ═════════════ -->
|
||||
<!-- ═════════════ Folders — section (+ peer) cards ═════════════ -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="section in visibleSections"
|
||||
v-for="section in contentSections"
|
||||
:key="section.id"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
@@ -202,83 +240,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual Peer Cards (All Files tab only) -->
|
||||
<template v-if="activeTab === 'all'">
|
||||
<div
|
||||
v-for="peer in peerNodes"
|
||||
:key="peer.did"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate" :title="peer.did">{{ peer.name || peerDisplayName(peer.did) }}</h3>
|
||||
<p class="text-xs text-white/40 truncate">{{ peer.name ? peer.did.slice(0, 20) + '...' : 'Peer node' }}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<!-- Individual Peer Cards -->
|
||||
<div
|
||||
v-for="peer in peerNodes"
|
||||
:key="peer.did"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
||||
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
||||
{{ peer.trust_level }}
|
||||
</span>
|
||||
<span class="text-white/30">Peer Node</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate" :title="peer.did">{{ peer.name || peerDisplayName(peer.did) }}</h3>
|
||||
<p class="text-xs text-white/40 truncate">{{ peer.name ? peer.did.slice(0, 20) + '...' : 'Peer node' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="peersLoading && peerNodes.length > 0"
|
||||
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5" 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 class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Refreshing peer nodes...
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
||||
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
||||
{{ peer.trust_level }}
|
||||
</span>
|
||||
<span class="text-white/30">Peer Node</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Peers placeholder (only if no peers found) -->
|
||||
<div
|
||||
v-if="!peersLoading && peerNodes.length === 0 && selectedCategory === 'all'"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
|
||||
<p class="text-xs text-white/50">Set up federation to share files with peers</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<div
|
||||
v-if="peersLoading && peerNodes.length > 0"
|
||||
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5" 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>
|
||||
Refreshing peer nodes...
|
||||
</div>
|
||||
|
||||
<!-- No Peers placeholder (only if no peers found) -->
|
||||
<div
|
||||
v-if="!peersLoading && peerNodes.length === 0"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
|
||||
No peers yet
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
|
||||
<p class="text-xs text-white/50">Set up federation to share files with peers</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
|
||||
No peers yet
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
@@ -286,13 +322,34 @@
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<!-- Not Installed Hint -->
|
||||
<div v-if="!fileBrowserRunning && !searchActive && activeTab !== 'peers'" class="glass-card p-8 mt-6 text-center">
|
||||
<!-- Not Installed Hint (Folders tab only — My Files has its own) -->
|
||||
<div v-if="!fileBrowserRunning && !searchActive && activeTab === 'folders'" class="glass-card p-8 mt-6 text-center">
|
||||
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||
Open App Store
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Share with peers -->
|
||||
<ShareModal
|
||||
v-if="shareTarget"
|
||||
:filename="shareTarget.name"
|
||||
:filepath="shareTarget.path"
|
||||
:is-dir="shareTarget.isDir"
|
||||
@close="shareTarget = null"
|
||||
@saved="shareTarget = null"
|
||||
/>
|
||||
|
||||
<!-- Media viewer for own files -->
|
||||
<MediaLightbox
|
||||
v-if="lightboxIndex !== null"
|
||||
:items="lightboxItems"
|
||||
:start-index="lightboxIndex"
|
||||
:show="lightboxIndex !== null"
|
||||
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
||||
:stream-url="cloudStore.streamUrl"
|
||||
@close="lightboxIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -300,20 +357,28 @@
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
import { useCloudStore } from '../stores/cloud'
|
||||
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
import FileCard from '../components/cloud/FileCard.vue'
|
||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const cloudStore = useCloudStore()
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const sectionCounts = ref<Record<string, number>>({})
|
||||
const countsLoading = ref(false)
|
||||
|
||||
// ── Tabs / categories / search state ────────────────────────────────────────
|
||||
type TabId = 'all' | 'mine' | 'peers'
|
||||
type TabId = 'folders' | 'mine' | 'peers'
|
||||
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
|
||||
|
||||
const TABS: Array<{ id: TabId; name: string }> = [
|
||||
{ id: 'all', name: 'All Files' },
|
||||
{ id: 'folders', name: 'Folders' },
|
||||
{ id: 'mine', name: 'My Files' },
|
||||
{ id: 'peers', name: 'Peer Files' },
|
||||
]
|
||||
@@ -324,10 +389,12 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
||||
{ id: 'documents', name: 'Documents' },
|
||||
]
|
||||
|
||||
const activeTab = ref<TabId>('all')
|
||||
const activeTab = ref<TabId>('folders')
|
||||
const selectedCategory = ref<CategoryId>('all')
|
||||
const searchQuery = ref('')
|
||||
const searchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||
// Categories narrow file LISTS; the Folders tab is already organized by kind.
|
||||
const showCategories = computed(() => activeTab.value !== 'folders' || searchActive.value)
|
||||
|
||||
interface PeerNode {
|
||||
did: string
|
||||
@@ -414,13 +481,6 @@ const contentSections: ContentSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** Section cards for the current tab, narrowed by the category pills. The
|
||||
* browse-everything "All Files" card only makes sense unfiltered. */
|
||||
const visibleSections = computed(() => {
|
||||
if (selectedCategory.value === 'all') return contentSections
|
||||
return contentSections.filter(s => s.id === selectedCategory.value)
|
||||
})
|
||||
|
||||
const SECTION_PATHS: Record<string, string> = {
|
||||
photos: '/Photos',
|
||||
music: '/Music',
|
||||
@@ -449,6 +509,94 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
// ── My Files (flat list of every own file across the sections) ──────────────
|
||||
const myFiles = ref<FileBrowserItem[]>([])
|
||||
const myFilesLoading = ref(false)
|
||||
const myFilesLoaded = ref(false)
|
||||
|
||||
/** Depth-limited walk of the section folders; flat file list, capped. */
|
||||
async function loadMyFiles(force = false) {
|
||||
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
|
||||
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
|
||||
myFilesLoading.value = true
|
||||
try {
|
||||
const ok = await cloudStore.init()
|
||||
if (!ok) return
|
||||
const out: FileBrowserItem[] = []
|
||||
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
||||
if (sectionId === 'files') continue // '/' would double-visit the sections
|
||||
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
||||
while (queue.length > 0 && out.length < 500) {
|
||||
const { path, depth } = queue.shift()!
|
||||
let items: FileBrowserItem[]
|
||||
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
||||
for (const item of items) {
|
||||
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
||||
if (item.isDir) {
|
||||
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
||||
} else {
|
||||
out.push({ ...item, path: itemPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
myFiles.value = out
|
||||
myFilesLoaded.value = true
|
||||
} finally {
|
||||
myFilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMyFiles = computed(() =>
|
||||
selectedCategory.value === 'all'
|
||||
? myFiles.value
|
||||
: myFiles.value.filter(f => categoryOf(f.name) === selectedCategory.value),
|
||||
)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'mine') void loadMyFiles()
|
||||
if (tab === 'peers') void loadPeerFiles()
|
||||
if (tab === 'folders') selectedCategory.value = 'all' // no hidden filter behind the cards
|
||||
})
|
||||
|
||||
// ── File actions shared by My Files rows and own-file search results ────────
|
||||
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
const lightboxItems = ref<FileBrowserItem[]>([])
|
||||
|
||||
function handleShare(path: string, name: string, isDir: boolean) {
|
||||
shareTarget.value = { path, name, isDir }
|
||||
}
|
||||
|
||||
/** Music opens in the global bottom-bar player (GlobalAudioPlayer in App.vue). */
|
||||
async function handlePlay(path: string, name: string) {
|
||||
const url = await cloudStore.streamUrl(path)
|
||||
audioPlayer.play(url, name)
|
||||
}
|
||||
|
||||
function handlePreview(path: string, context: FileBrowserItem[]) {
|
||||
// MediaLightbox filters to media internally; index within that filtered list.
|
||||
const mediaItems = context.filter(item => {
|
||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||
const cat = getFileCategory(ext, item.isDir)
|
||||
return cat === 'image' || cat === 'video' || cat === 'audio'
|
||||
})
|
||||
const idx = mediaItems.findIndex(item => item.path === path)
|
||||
lightboxItems.value = context
|
||||
lightboxIndex.value = idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
async function handleDelete(path: string) {
|
||||
try {
|
||||
await cloudStore.deleteItem(path)
|
||||
myFiles.value = myFiles.value.filter(f => f.path !== path)
|
||||
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : 'Delete failed'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer files (aggregated across every federation peer) ────────────────────
|
||||
interface PeerFileEntry {
|
||||
key: string
|
||||
@@ -526,19 +674,15 @@ const filteredPeerFiles = computed(() =>
|
||||
: peerFiles.value.filter(f => f.category === selectedCategory.value),
|
||||
)
|
||||
|
||||
// Fetch the aggregated list lazily, the first time the tab (or a search) needs it.
|
||||
watch(activeTab, (tab) => { if (tab === 'peers') void loadPeerFiles() })
|
||||
|
||||
// ── Search (own files + all peer files) ─────────────────────────────────────
|
||||
interface SearchResult {
|
||||
key: string
|
||||
name: string
|
||||
detail: string
|
||||
category: Exclude<CategoryId, 'all'>
|
||||
source: 'mine' | 'peer'
|
||||
item?: FileBrowserItem
|
||||
peerName?: string
|
||||
peerOnion?: string
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
const searching = ref(false)
|
||||
@@ -553,51 +697,24 @@ watch(searchQuery, () => {
|
||||
searchTimer = setTimeout(() => void runSearch(), 350)
|
||||
})
|
||||
|
||||
/** Depth-limited walk of the own-file sections via the FileBrowser API. */
|
||||
async function searchOwnFiles(query: string): Promise<SearchResult[]> {
|
||||
if (!fileBrowserRunning.value) return []
|
||||
const q = query.toLowerCase()
|
||||
const out: SearchResult[] = []
|
||||
try {
|
||||
const ok = await fileBrowserClient.login()
|
||||
if (!ok) return []
|
||||
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
||||
if (sectionId === 'files') continue // '/' would double-visit the sections
|
||||
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
||||
while (queue.length > 0 && out.length < 100) {
|
||||
const { path, depth } = queue.shift()!
|
||||
let items
|
||||
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
||||
for (const item of items) {
|
||||
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
||||
if (item.isDir) {
|
||||
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
||||
} else if (item.name.toLowerCase().includes(q)) {
|
||||
out.push({
|
||||
key: `mine:${itemPath}`,
|
||||
name: item.name,
|
||||
detail: itemPath,
|
||||
category: categoryOf(item.name),
|
||||
source: 'mine',
|
||||
sectionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* own-file search is best-effort; peer results still render */ }
|
||||
return out
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const query = searchQuery.value.trim()
|
||||
const seq = ++searchSeq
|
||||
searching.value = true
|
||||
try {
|
||||
// Peer catalog piggybacks on the aggregated list (cached after first load).
|
||||
const [mine] = await Promise.all([searchOwnFiles(query), loadPeerFiles()])
|
||||
// Both corpora are cached flat lists after their first load.
|
||||
await Promise.all([loadMyFiles(), loadPeerFiles()])
|
||||
if (seq !== searchSeq) return // a newer query superseded this run
|
||||
const q = query.toLowerCase()
|
||||
const mine: SearchResult[] = myFiles.value
|
||||
.filter(f => f.name.toLowerCase().includes(q))
|
||||
.map(f => ({
|
||||
key: `mine:${f.path}`,
|
||||
name: f.name,
|
||||
detail: f.path,
|
||||
category: categoryOf(f.name),
|
||||
item: f,
|
||||
}))
|
||||
const peers: SearchResult[] = peerFiles.value
|
||||
.filter(f => f.filename.toLowerCase().includes(q))
|
||||
.map(f => ({
|
||||
@@ -605,7 +722,6 @@ async function runSearch() {
|
||||
name: f.filename,
|
||||
detail: `${formatSize(f.sizeBytes)}${f.priceSats ? ` · ${f.priceSats.toLocaleString()} sats` : ''}`,
|
||||
category: f.category,
|
||||
source: 'peer' as const,
|
||||
peerName: f.peerName,
|
||||
peerOnion: f.peerOnion,
|
||||
}))
|
||||
@@ -621,13 +737,10 @@ const filteredSearchResults = computed(() =>
|
||||
: searchResults.value.filter(r => r.category === selectedCategory.value),
|
||||
)
|
||||
|
||||
function openSearchResult(r: SearchResult) {
|
||||
if (r.source === 'peer' && r.peerOnion) {
|
||||
router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })
|
||||
} else if (r.sectionId) {
|
||||
router.push({ name: 'cloud-folder', params: { folderId: r.sectionId } })
|
||||
}
|
||||
}
|
||||
/** Own-file items among the current search results (lightbox context). */
|
||||
const searchMineItems = computed(() =>
|
||||
filteredSearchResults.value.flatMap(r => (r.item ? [r.item] : [])),
|
||||
)
|
||||
|
||||
// ── Existing counts / peers loading ──────────────────────────────────────────
|
||||
async function loadCounts() {
|
||||
@@ -684,3 +797,50 @@ function openSection(section: ContentSection) {
|
||||
|
||||
defineExpose({ loadPeers })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Mobile-only top-level tab switcher — deliberately a DIFFERENT look from the
|
||||
neutral category pills below it (orange tint). Desktop keeps the standard
|
||||
mode-switcher styling. */
|
||||
.cloud-tab-switcher {
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(247, 147, 26, 0.08);
|
||||
border: 1px solid rgba(247, 147, 26, 0.22);
|
||||
}
|
||||
|
||||
.cloud-tab-btn {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.cloud-tab-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.cloud-tab-btn-active {
|
||||
background: rgba(247, 147, 26, 0.25);
|
||||
color: #ffd9a8;
|
||||
}
|
||||
|
||||
/* Mobile: the three tabs share the full width equally. */
|
||||
.cloud-tab-switcher-full {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cloud-tab-switcher-full .cloud-tab-btn {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
padding: 0.6rem 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,9 +52,11 @@
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Background overlay - uniform 0.2 opacity -->
|
||||
<!-- Background overlay. The web5/server backdrop is a much lighter image
|
||||
than the others, so it gets a heavier scrim for text contrast. -->
|
||||
<div
|
||||
class="fixed inset-0 pointer-events-none bg-black/20"
|
||||
class="fixed inset-0 pointer-events-none bg-black transition-opacity duration-500"
|
||||
:class="isWeb5Bg ? 'opacity-[0.45]' : 'opacity-20'"
|
||||
style="z-index: -5;"
|
||||
/>
|
||||
|
||||
@@ -190,6 +192,10 @@ const backgroundImage = computed(() => {
|
||||
return 'bg-home.webp'
|
||||
})
|
||||
|
||||
// bg-web5.jpg (web5 + server sections) is bright — the scrim overlay deepens
|
||||
// while it's showing so light text keeps its contrast.
|
||||
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
|
||||
|
||||
const isDarkRoute = computed(() => {
|
||||
const p = route.path
|
||||
return p.includes('/dashboard/web5') ||
|
||||
@@ -369,8 +375,11 @@ onMounted(() => {
|
||||
if (loginTransition.justCompletedOnboarding) {
|
||||
// Full glitchy reveal — only on the very first dashboard entry
|
||||
// right after onboarding (one-time event, persists in feel).
|
||||
playDashboardLoadOomph()
|
||||
// force=true: onboarding is already marked complete by now, so the
|
||||
// un-forced call would gate itself silent.
|
||||
playDashboardLoadOomph(true)
|
||||
showZoomIn.value = true
|
||||
loginTransition.setIntroCinematicPlaying(true)
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustCompletedOnboarding(false)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
@@ -381,7 +390,10 @@ onMounted(() => {
|
||||
scheduledTimeout(triggerRevealGlitch, 500)
|
||||
scheduledTimeout(triggerRevealGlitch, 1200)
|
||||
scheduledTimeout(triggerRevealGlitch, 2000)
|
||||
scheduledTimeout(() => { showZoomIn.value = false }, 8000)
|
||||
scheduledTimeout(() => {
|
||||
showZoomIn.value = false
|
||||
loginTransition.setIntroCinematicPlaying(false)
|
||||
}, 8000)
|
||||
scheduledTimeout(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
@@ -403,6 +415,9 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('dashboard-active')
|
||||
// Timers are cleared below; make sure overlays waiting on the cinematic
|
||||
// aren't left blocked forever if we unmount mid-reveal.
|
||||
loginTransition.setIntroCinematicPlaying(false)
|
||||
window.removeEventListener('keydown', handleKioskShortcuts)
|
||||
for (const id of pendingTimers) clearTimeout(id)
|
||||
pendingTimers.length = 0
|
||||
|
||||
@@ -150,6 +150,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile companion app banner — opens the download/pairing modal -->
|
||||
<CompanionBanner />
|
||||
|
||||
<!-- Category Section Divider -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">all</span>
|
||||
@@ -239,6 +242,7 @@ import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout
|
||||
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import CompanionBanner from './discover/CompanionBanner.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
|
||||
@@ -426,6 +426,8 @@ async function handleSetup() {
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustCompletedOnboarding(true)
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
// First password setup counts as the first login (video → static rotation).
|
||||
try { localStorage.setItem('neode_first_login_done', '1') } catch { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
@@ -477,6 +479,7 @@ async function handleLogin() {
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
consumeOnboardingFinale()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
@@ -512,6 +515,7 @@ async function handleTotpVerify() {
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
consumeOnboardingFinale()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
@@ -533,11 +537,32 @@ async function handleTotpVerify() {
|
||||
}
|
||||
}
|
||||
|
||||
/** A login right after the onboarding wizard (flag set by OnboardingDone)
|
||||
* gets the FULL dashboard entrance — zoom + oomph — even though it's a
|
||||
* regular password login (e.g. the demo). Subsequent logins stay
|
||||
* deliberately low-key (justLoggedIn only).
|
||||
* Also stamps neode_first_login_done: the login page keeps the intro VIDEO
|
||||
* background until someone has logged in once (OnboardingWrapper reads it
|
||||
* to pick video vs the rotating static backgrounds). */
|
||||
function consumeOnboardingFinale() {
|
||||
try {
|
||||
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
|
||||
sessionStorage.removeItem('archy_onboarding_finale')
|
||||
loginTransition.setJustCompletedOnboarding(true)
|
||||
}
|
||||
localStorage.setItem('neode_first_login_done', '1')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function replayIntro() {
|
||||
// Clear the intro seen flag
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Demo: also clear the per-day gate so the intro plays again now.
|
||||
if (IS_DEMO) clearDemoIntroSeen()
|
||||
// On an onboarded node App.vue instantly re-marks the intro as seen (that's
|
||||
// what keeps fresh browsers on already-onboarded nodes from replaying it) —
|
||||
// this explicit one-shot flag tells it the replay is deliberate.
|
||||
try { sessionStorage.setItem('archipelago_replay_intro', '1') } catch { /* ignore */ }
|
||||
// Navigate to root to trigger splash screen
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ onUnmounted(() => {
|
||||
|
||||
function goToLogin() {
|
||||
playNavSound('action')
|
||||
// The login that follows the wizard gets the full dashboard entrance
|
||||
// (zoom + oomph) even when it's a regular password login (e.g. the demo) —
|
||||
// Login.vue consumes this flag on success.
|
||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -56,28 +56,35 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const ctaButton = ref<HTMLButtonElement | null>(null)
|
||||
const isDemo = IS_DEMO
|
||||
|
||||
onMounted(() => {
|
||||
// Demo: once the visitor has seen the intro today, don't auto-replay it again
|
||||
// until tomorrow (they can still use "Replay Intro" on the login screen).
|
||||
if (IS_DEMO) markDemoIntroSeen()
|
||||
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
|
||||
setTimeout(() => {
|
||||
ctaButton.value?.focus({ preventScroll: true })
|
||||
}, 2100)
|
||||
})
|
||||
|
||||
/** Any exit from the intro INTO login is the end of the cinematic — hand the
|
||||
* login the one-shot finale flag so the dashboard plays its full first-entry
|
||||
* reveal (zoom + oomph). OnboardingDone sets the same flag at the end of the
|
||||
* full wizard; these are the shortcut exits that used to skip it (which is
|
||||
* why the demo never got the big entrance). */
|
||||
function armOnboardingFinale() {
|
||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function goToOptions() {
|
||||
playNavSound('action')
|
||||
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
|
||||
// to login, which is prefilled with the demo password.
|
||||
if (isDemo) {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
armOnboardingFinale()
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
@@ -92,6 +99,7 @@ function goToRestore() {
|
||||
function goToLogin() {
|
||||
playNavSound('action')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
armOnboardingFinale()
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
@pause.prevent="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Login: static background + archipelago-style glitch (no zoom) -->
|
||||
@@ -86,22 +86,22 @@ const videoBackgroundRoutes = ['/onboarding/intro', '/login']
|
||||
// Login uses video when coming from splash, or static + glitch when direct
|
||||
const isLoginRoute = computed(() => route.path === '/login')
|
||||
|
||||
// True once onboarding is complete. Used to skip the intro video on
|
||||
// the /login route so that returning (logged-out) users go straight
|
||||
// to the screensaver-style static + glitch background instead of
|
||||
// replaying the full intro every time.
|
||||
const onboardingDone = computed(() => {
|
||||
|
||||
// Check if current route should use video background.
|
||||
// The FIRST login (nobody has logged in / set a password yet) keeps the VIDEO
|
||||
// running so the splash → login handoff is one continuous shot; only after a
|
||||
// successful login does /login switch to the rotating static backgrounds.
|
||||
// Login.vue sets neode_first_login_done on every successful login.
|
||||
const hasLoggedInBefore = () => {
|
||||
try {
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
return localStorage.getItem('neode_first_login_done') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Check if current route should use video background
|
||||
}
|
||||
const useVideoBackground = computed(() => {
|
||||
if (!videoBackgroundRoutes.includes(route.path)) return false
|
||||
if (route.path === '/login' && onboardingDone.value) return false
|
||||
if (route.path === '/login' && hasLoggedInBefore()) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -122,9 +122,12 @@ const routeBackgrounds: Record<string, string> = {
|
||||
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
||||
}
|
||||
|
||||
// Rotate the login background so the lock screen doesn't look
|
||||
// identical on every logout. Cycles through bg-intro-1..6 using a
|
||||
// counter persisted to localStorage so subsequent visits advance.
|
||||
// Until the user has logged in (or set their password) once, the login
|
||||
// background stays PINNED to the same still the intro video ends on — the
|
||||
// video → login handoff only reads as one continuous shot when both show the
|
||||
// identical frame. From the second login onward the lock screen rotates
|
||||
// through bg-intro-1..6 for variety (counter persisted in localStorage).
|
||||
// Login.vue sets neode_first_login_done on every successful login.
|
||||
const LOGIN_BACKGROUNDS = [
|
||||
'bg-intro-1.webp',
|
||||
'bg-intro-2.jpg',
|
||||
@@ -135,13 +138,14 @@ const LOGIN_BACKGROUNDS = [
|
||||
]
|
||||
function pickNextLoginBackground(): string {
|
||||
try {
|
||||
if (localStorage.getItem('neode_first_login_done') !== '1') return 'bg-intro.jpg'
|
||||
const raw = localStorage.getItem('neode_login_bg_idx')
|
||||
const prev = raw !== null ? parseInt(raw, 10) : -1
|
||||
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
|
||||
localStorage.setItem('neode_login_bg_idx', String(next))
|
||||
return LOGIN_BACKGROUNDS[next]!
|
||||
} catch {
|
||||
return LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]!
|
||||
return 'bg-intro.jpg'
|
||||
}
|
||||
}
|
||||
const loginBackground = ref(pickNextLoginBackground())
|
||||
@@ -678,5 +682,30 @@ video.bg-layer {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Kiosk (software compositor): flatten THIS screen's background stack.
|
||||
Same failure mode the dashboard kiosk fix addressed — perspective +
|
||||
preserve-3d layers and mix-blend-mode overlays fail to repaint under
|
||||
Chromium software compositing, so the black body fill shows through.
|
||||
The dashboard-only overrides never reached this scoped stack, which is
|
||||
why the kiosk login/onboarding background still went black. Keep 2D
|
||||
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
|
||||
glitch overlays. */
|
||||
:global(html.kiosk-mode) .bg-perspective-container,
|
||||
:global(html.kiosk-mode) .perspective-container {
|
||||
perspective: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode) .bg-layer,
|
||||
:global(html.kiosk-mode) .view-wrapper {
|
||||
transform: none !important;
|
||||
transform-style: flat !important;
|
||||
backface-visibility: visible !important;
|
||||
will-change: auto !important;
|
||||
filter: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode) .login-glitch-layer,
|
||||
:global(html.kiosk-mode) .login-glitch-scan {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
import { IS_DEMO, demoIntroSeenToday } from '@/composables/useDemoIntro'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import BootScreen from '@/components/BootScreen.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const showBootScreen = ref(false)
|
||||
|
||||
/**
|
||||
* Public demo: replay the intro on every visit, but at most once per calendar
|
||||
* day per browser. If already seen today → straight to login; otherwise → intro.
|
||||
* Public demo: every fresh boot of the app (first visit OR browser refresh at
|
||||
* the root) replays the intro for the full effect. In-session SPA navigation
|
||||
* never re-triggers it — this only runs when the app boots at '/'.
|
||||
*/
|
||||
function demoRoute() {
|
||||
const dest = demoIntroSeenToday() ? '/login' : '/onboarding/intro'
|
||||
log('demoRoute', { dest })
|
||||
router.replace(dest).catch(() => {})
|
||||
log('demoRoute', { dest: '/onboarding/intro' })
|
||||
router.replace('/onboarding/intro').catch(() => {})
|
||||
}
|
||||
|
||||
function log(msg: string, data?: unknown) {
|
||||
@@ -40,10 +40,10 @@ function log(msg: string, data?: unknown) {
|
||||
sessionStorage.setItem('archipelago_boot_log', prev + entry + '\n')
|
||||
}
|
||||
|
||||
async function quickHealthCheck(): Promise<boolean> {
|
||||
async function quickHealthCheck(timeoutMs = 2000): Promise<boolean> {
|
||||
try {
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 2000)
|
||||
const t = setTimeout(() => ac.abort(), timeoutMs)
|
||||
const res = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -132,7 +132,16 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const isUp = await quickHealthCheck()
|
||||
// First-boot backends can be up but slow (image loads, first-run podman
|
||||
// work), so a single 2s echo timing out used to flash the BootScreen and
|
||||
// then hard-reload seconds later ("loads, refreshes, loads again" on
|
||||
// kiosk). Give it a second, more patient attempt before concluding the
|
||||
// server is down.
|
||||
let isUp = await quickHealthCheck()
|
||||
if (!isUp) {
|
||||
log('healthCheck retry with longer timeout')
|
||||
isUp = await quickHealthCheck(6000)
|
||||
}
|
||||
log('production flow', { isUp })
|
||||
|
||||
if (isUp) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Cloud from '../Cloud.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
@@ -43,7 +44,7 @@ describe('Cloud peer list', () => {
|
||||
it('keeps peer nodes visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
|
||||
|
||||
const wrapper = mount(Cloud)
|
||||
const wrapper = mount(Cloud, { global: { plugins: [createPinia()] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
|
||||
@@ -7,11 +8,14 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
// confirms writing it down (`acknowledged`), the card renders as a
|
||||
// prominent first-launch prompt.
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const available = ref(false)
|
||||
const acknowledged = ref(true)
|
||||
const statusLoaded = ref(false)
|
||||
|
||||
async function loadStatus() {
|
||||
async function loadStatus(): Promise<boolean> {
|
||||
try {
|
||||
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
|
||||
method: 'lnd.seed-backup-status',
|
||||
@@ -20,12 +24,28 @@ async function loadStatus() {
|
||||
available.value = !!res.available
|
||||
acknowledged.value = !!res.acknowledged
|
||||
statusLoaded.value = true
|
||||
return true
|
||||
} catch {
|
||||
statusLoaded.value = false
|
||||
// Leave statusLoaded as-is; a one-off RPC blip must not permanently
|
||||
// hide the card (the global banner may have just promised it's here).
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStatus)
|
||||
onMounted(async () => {
|
||||
// Retry a few times — on fresh installs the backend can still be warming
|
||||
// up when the user lands here straight from the backup banner.
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
if (await loadStatus()) break
|
||||
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)))
|
||||
}
|
||||
// Deep link from the "Back up your Lightning seed" banner: open the
|
||||
// reveal flow directly so the click visibly does something.
|
||||
if (route.query['seed-backup'] === '1') {
|
||||
router.replace({ query: { ...route.query, 'seed-backup': undefined } }).catch(() => {})
|
||||
if (statusLoaded.value && available.value) openReveal()
|
||||
}
|
||||
})
|
||||
|
||||
// Reveal modal — re-auth gated (password + 2FA when enabled), same UX as
|
||||
// the recovery-phrase reveal in Settings → Backup.
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
@@ -62,8 +63,23 @@ const hasConnIssue = computed(
|
||||
)
|
||||
|
||||
const SHOW_DELAY_MS = 2500
|
||||
// Right after the page loads or the tab returns to the foreground, a dead
|
||||
// WebSocket is the NORMAL state (browsers kill sockets in background tabs;
|
||||
// first paint races the initial connect). Reconnecting takes longer than
|
||||
// the steady-state grace on real links — radio wake-up, TLS, proxies — so
|
||||
// the 2.5s window made every tab-return flash "Connection lost" on a
|
||||
// perfectly healthy node. Give those moments a much longer runway; keep
|
||||
// the short window for genuine mid-session drops.
|
||||
const RESUME_GRACE_WINDOW_MS = 15000
|
||||
const RESUME_SHOW_DELAY_MS = 10000
|
||||
const showConnIssue = ref(false)
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastResumeAt = Date.now() // mount counts as a resume (initial connect)
|
||||
|
||||
function onVisibilityResume() {
|
||||
if (!document.hidden) lastResumeAt = Date.now()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityResume)
|
||||
|
||||
function clearTimer() {
|
||||
if (pendingTimer) {
|
||||
@@ -76,11 +92,17 @@ watch(
|
||||
hasConnIssue,
|
||||
(issue) => {
|
||||
clearTimer()
|
||||
// The demo runs against a local mock — a connection banner there is
|
||||
// meaningless noise on what should be a flawless showcase.
|
||||
if (IS_DEMO) return
|
||||
if (issue) {
|
||||
const delay = Date.now() - lastResumeAt < RESUME_GRACE_WINDOW_MS
|
||||
? RESUME_SHOW_DELAY_MS
|
||||
: SHOW_DELAY_MS
|
||||
pendingTimer = setTimeout(() => {
|
||||
showConnIssue.value = true
|
||||
pendingTimer = null
|
||||
}, SHOW_DELAY_MS)
|
||||
}, delay)
|
||||
} else {
|
||||
// Recovered before the grace window elapsed — hide at once.
|
||||
showConnIssue.value = false
|
||||
@@ -89,7 +111,10 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(clearTimer)
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
document.removeEventListener('visibilitychange', onVisibilityResume)
|
||||
})
|
||||
|
||||
// Debounced visual states the template renders.
|
||||
const showReconnecting = computed(
|
||||
|
||||
@@ -741,6 +741,21 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
|
||||
transform: translateZ(0) scale(1.05) rotateY(0deg) !important;
|
||||
}
|
||||
|
||||
/* Kiosk: chromium runs software-composited (--in-process-gpu or
|
||||
--disable-gpu, single raster thread). 3D-transformed will-change layers
|
||||
routinely fail to repaint there after the first background swap, leaving
|
||||
the container's black fill on screen. Flatten the stack to plain 2D
|
||||
opacity crossfades in kiosk mode. */
|
||||
html.kiosk-mode .dashboard-view .bg-perspective-container {
|
||||
perspective: none;
|
||||
}
|
||||
html.kiosk-mode .dashboard-view .bg-layer {
|
||||
transform: none !important;
|
||||
transform-style: flat;
|
||||
will-change: auto;
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
/* Background glitch effect layers - World Fair style */
|
||||
.bg-glitch-layer-1,
|
||||
.bg-glitch-layer-2,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<!-- Companion app banner — same format as the featured app banner, with a
|
||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||
Install button) opens the Remote Companion download/pairing modal. -->
|
||||
<div
|
||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/bg-intro-4.webp"
|
||||
alt=""
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
|
||||
/>
|
||||
<div class="featured-banner-overlay companion-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">companion</span>
|
||||
<span class="text-white/50 text-sm font-mono">REMOTE CONTROL // IN YOUR POCKET</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">Your Node. In Your Pocket.</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">
|
||||
The Archipelago Companion puts your node on your phone — remote control and typing,
|
||||
your cloud files on the go, and one scan to connect. Sovereignty doesn't stay home.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click.stop="openCompanionIntro()"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Install</button>
|
||||
<span class="text-white/40 text-sm">Archipelago Companion · Android</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="companion-phone-wrap" aria-hidden="true">
|
||||
<div class="companion-phone">
|
||||
<span class="companion-phone-lens"></span>
|
||||
<img src="/assets/img/companion-phone-screen.webp" alt="" class="companion-phone-screen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.companion-banner {
|
||||
border-color: rgba(251, 146, 60, 0.25);
|
||||
}
|
||||
|
||||
/* Keep the copy clear of the phone mockup on wide screens */
|
||||
@media (min-width: 768px) {
|
||||
.companion-banner-overlay {
|
||||
padding-right: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone-wrap {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.companion-phone-wrap {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 3.5rem;
|
||||
z-index: 2;
|
||||
transform: rotate(6deg);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.companion-banner:hover .companion-phone-wrap {
|
||||
transform: rotate(4deg) translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
padding: 9px;
|
||||
border-radius: 30px;
|
||||
background: #0b0b12;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.55),
|
||||
0 0 46px rgba(251, 146, 60, 0.28),
|
||||
inset 0 0 2px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Punch-hole camera */
|
||||
.companion-phone-lens {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.companion-phone-screen {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/* Screen glare */
|
||||
.companion-phone::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 9px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 28%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -105,6 +105,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'netbird', title: 'NetBird', version: '0.71.2', description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.', icon: '/assets/img/app-icons/netbird.svg', author: 'NetBird', dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0', repoUrl: 'https://github.com/netbirdio/netbird' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ id: 'fedimint', title: 'Fedimint Guardian', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||
{ id: 'fedimint-clientd', title: 'Fedimint Client', version: '0.8.0', description: 'Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fmcd:0.8.1`, repoUrl: 'https://github.com/minmoto/fmcd' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
|
||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
|
||||
@@ -122,6 +123,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
'fedimint-clientd': ['fedimint-clientd'],
|
||||
electrumx: ['electrumx'],
|
||||
grafana: ['grafana'],
|
||||
jellyfin: ['jellyfin'],
|
||||
|
||||
@@ -368,8 +368,10 @@ onMounted(() => load())
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- mb-0 overrides the pill's default mb-4, which pushed it above the
|
||||
title's centerline in this inline header row. -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<BackButton @click="goBack" />
|
||||
<BackButton desktop-margin="mb-0" @click="goBack" />
|
||||
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,40 +30,47 @@
|
||||
</svg>
|
||||
Refreshing Tor services...
|
||||
</div>
|
||||
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
|
||||
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
|
||||
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
|
||||
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
|
||||
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
|
||||
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
|
||||
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
|
||||
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
|
||||
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
|
||||
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
|
||||
</div>
|
||||
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
|
||||
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
|
||||
<p v-else class="text-white/30 text-xs">Disabled</p>
|
||||
</div>
|
||||
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
|
||||
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
|
||||
<p v-else class="text-white/30 text-xs">Disabled</p>
|
||||
<ToggleSwitch class="shrink-0" :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
v-if="svc.onion_address && svc.enabled"
|
||||
@click="$emit('rotateService', svc.name)"
|
||||
:disabled="torRotating === svc.name"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||
</button>
|
||||
<!-- Actions in their own 50/50 row: Delete on the LEFT, far from the
|
||||
toggle above, so a rushed thumb can't hit the wrong control. -->
|
||||
<div
|
||||
v-if="svc.name !== 'archipelago' || (svc.onion_address && svc.enabled)"
|
||||
class="grid grid-cols-2 gap-2 mt-3"
|
||||
>
|
||||
<button
|
||||
v-if="svc.name !== 'archipelago'"
|
||||
@click="$emit('deleteService', svc.name)"
|
||||
:disabled="torDeleting === svc.name"
|
||||
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
|
||||
class="glass-button py-2 rounded-lg text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
:class="{ 'col-span-2': !(svc.onion_address && svc.enabled) }"
|
||||
:title="'Delete ' + svc.name + ' hidden service'"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ torDeleting === svc.name ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="svc.onion_address && svc.enabled"
|
||||
@click="$emit('rotateService', svc.name)"
|
||||
:disabled="torRotating === svc.name"
|
||||
class="glass-button py-2 rounded-lg text-xs disabled:opacity-50"
|
||||
:class="{ 'col-span-2': svc.name === 'archipelago' }"
|
||||
>
|
||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||
</button>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -362,6 +362,25 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.101-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.101-alpha</span>
|
||||
<span class="text-xs text-white/40">July 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.</p>
|
||||
<p>Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step.</p>
|
||||
<p>"Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".</p>
|
||||
<p>Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.</p>
|
||||
<p>The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.</p>
|
||||
<p>Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.</p>
|
||||
<p>The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — "Replay Intro" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.</p>
|
||||
<p>Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.</p>
|
||||
<p>The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.</p>
|
||||
<p>Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.100-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -126,6 +126,31 @@ async function deleteBackup(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Download the encrypted backup archive through the browser — the only
|
||||
// path off the node for remote/companion users with no USB access.
|
||||
const downloadingId = ref<string | null>(null)
|
||||
async function downloadBackup(id: string) {
|
||||
downloadingId.value = id
|
||||
try {
|
||||
const res = await fetch(`/api/blob/backup/${encodeURIComponent(id)}`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `archipelago-backup-${id}.bak`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
showBackupStatus('Backup download started', 'success')
|
||||
} catch {
|
||||
showBackupStatus('Backup download failed', 'error')
|
||||
} finally {
|
||||
downloadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery phrase reveal — re-auth gated (password + 2FA when enabled).
|
||||
const showRevealModal = ref(false)
|
||||
const revealPassword = ref('')
|
||||
@@ -364,6 +389,9 @@ defineExpose({ loadBackups })
|
||||
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
|
||||
{{ usbCopyingId === b.id ? '...' : 'USB' }}
|
||||
</button>
|
||||
<button @click="downloadBackup(b.id)" :disabled="downloadingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-green-400 disabled:opacity-50" title="Download backup file">
|
||||
{{ downloadingId === b.id ? '...' : 'Download' }}
|
||||
</button>
|
||||
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
|
||||
{{ t('common.restore') }}
|
||||
</button>
|
||||
|
||||
@@ -11,21 +11,6 @@
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
|
||||
</div>
|
||||
<div class="web5-card-actions-top gap-2 shrink-0">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: stacked layout -->
|
||||
<div class="md:hidden mb-4">
|
||||
@@ -174,7 +159,9 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4 space-y-3">
|
||||
<div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
|
||||
<!-- Always the bottom 50/50 pair, every screen size — consistent with
|
||||
the other web5 cards' full-width bottom actions. -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
@@ -182,37 +169,13 @@
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
@click="refreshActiveTab"
|
||||
:disabled="loadingPeers || loadingRequests"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="nodesContainerTab === 'trusted'"
|
||||
@click="discoverAndAddPeers"
|
||||
:disabled="discovering"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="nodesContainerTab === 'observers'"
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="loadConnectionRequests"
|
||||
:disabled="loadingRequests"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +291,6 @@ const observers = ref<Peer[]>(cached.observers ?? [])
|
||||
const loadingPeers = ref(false)
|
||||
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
|
||||
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
|
||||
const discovering = ref(false)
|
||||
|
||||
// Send message modal
|
||||
const showSendMessageModal = ref(false)
|
||||
@@ -378,6 +340,12 @@ function switchToRequestsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// The single bottom Refresh acts on whichever tab is open.
|
||||
function refreshActiveTab() {
|
||||
if (nodesContainerTab.value === 'requests') void loadConnectionRequests()
|
||||
else void loadPeers()
|
||||
}
|
||||
|
||||
async function loadPeers() {
|
||||
const hadPeers = peers.value.length > 0 || observers.value.length > 0
|
||||
loadingPeers.value = true
|
||||
@@ -456,28 +424,6 @@ async function sendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAndAddPeers() {
|
||||
discovering.value = true
|
||||
try {
|
||||
const res = await rpcClient.discoverNodes()
|
||||
const nodes = res.nodes || []
|
||||
for (const n of nodes) {
|
||||
if (n.onion && n.pubkey) {
|
||||
try {
|
||||
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadPeers()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Discover failed:', e)
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnectionRequests() {
|
||||
const hadRequests = connectionRequests.value.length > 0
|
||||
loadingRequests.value = true
|
||||
|
||||
@@ -61,9 +61,91 @@
|
||||
<div
|
||||
v-for="(identity, idx) in managedIdentities"
|
||||
:key="identity.id"
|
||||
:class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': idx }"
|
||||
>
|
||||
<!-- Narrow containers (phones): profile-card layout with banner +
|
||||
overlapping avatar. Hidden on wide containers where the classic
|
||||
row below renders instead. -->
|
||||
<div class="identity-card-m rounded-xl overflow-hidden bg-white/[0.08]">
|
||||
<div class="relative h-16" :class="bannerFallback(identity)">
|
||||
<img
|
||||
v-if="identity.profile?.banner"
|
||||
:src="displayableUrl(identity.profile.banner)"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<div class="flex items-end justify-between -mt-7">
|
||||
<button @click="openProfileEditor(identity)" class="relative w-14 h-14 rounded-full overflow-hidden ring-4 ring-black/60 group shrink-0" title="Edit profile">
|
||||
<img
|
||||
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
|
||||
:src="displayableUrl(identity.profile.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="() => { listPictureFailed[identity.id] = true }"
|
||||
/>
|
||||
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
}">
|
||||
<span class="text-lg font-bold" :class="{
|
||||
'text-blue-400': identity.purpose === 'personal',
|
||||
'text-orange-400': identity.purpose === 'business',
|
||||
'text-purple-400': identity.purpose === 'anonymous',
|
||||
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex items-center gap-1 pt-8">
|
||||
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
|
||||
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span class="text-white font-semibold text-sm truncate">{{ identity.profile?.display_name || identity.name }}</span>
|
||||
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">★</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
|
||||
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
|
||||
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
|
||||
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
|
||||
}">{{ identity.purpose }}</span>
|
||||
</div>
|
||||
<p v-if="identity.profile?.about" class="text-white/60 text-xs mt-1 leading-relaxed">{{ identity.profile.about }}</p>
|
||||
<div class="mt-2 space-y-0.5">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
|
||||
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="identity.nostr_npub" class="flex items-center gap-1 min-w-0">
|
||||
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
|
||||
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wide containers: the original row — desktop appearance unchanged -->
|
||||
<div class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg">
|
||||
<div class="identity-row-main flex items-center gap-4 min-w-0">
|
||||
<!-- Avatar -->
|
||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
||||
@@ -134,6 +216,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -465,6 +548,16 @@ watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false
|
||||
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
|
||||
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
|
||||
// URLs and plain external URLs pass through untouched.
|
||||
// Gradient banner backdrop for the narrow profile card when the identity has
|
||||
// no banner image, tinted by purpose to match the avatar/chip colors.
|
||||
function bannerFallback(identity: ManagedIdentity): string {
|
||||
switch (identity.purpose) {
|
||||
case 'business': return 'bg-gradient-to-br from-orange-500/40 to-amber-500/15'
|
||||
case 'anonymous': return 'bg-gradient-to-br from-purple-500/40 to-fuchsia-500/15'
|
||||
default: return 'bg-gradient-to-br from-blue-500/40 to-indigo-500/15'
|
||||
}
|
||||
}
|
||||
|
||||
function displayableUrl(url: string | null | undefined): string {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('data:') || url.startsWith('/')) return url
|
||||
@@ -710,8 +803,21 @@ defineExpose({ loadIdentities, managedIdentities })
|
||||
container-name: identities-card;
|
||||
}
|
||||
|
||||
/* Narrow containers (default): the profile card renders and the classic row
|
||||
is hidden. Wide containers: the original desktop row — untouched — and the
|
||||
profile card hides. Doubled selectors out-rank the Tailwind utilities on
|
||||
the same elements regardless of stylesheet order. */
|
||||
.identities-card .identity-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container identities-card (min-width: 560px) {
|
||||
.identity-row {
|
||||
.identities-card .identity-card-m {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.identities-card .identity-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -23,6 +25,10 @@ interface ServicePricing {
|
||||
accepted_mints: string[]
|
||||
}
|
||||
|
||||
// ── Tabs ──
|
||||
const tab = ref<'dashboard' | 'configure'>('dashboard')
|
||||
|
||||
// ── Configure state ──
|
||||
const services = ref<ServicePricing[]>([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
@@ -32,7 +38,7 @@ const statusIsError = ref(false)
|
||||
|
||||
// "Free everything" is the default — every service ships disabled. The banner
|
||||
// reassures the user nothing is being charged for until they opt in.
|
||||
const allFree = computed(() => services.value.every((s) => !s.enabled))
|
||||
const allFree = computed(() => services.value.every((s) => !s.enabled) && !tollgate.value?.enabled)
|
||||
|
||||
function showStatus(msg: string, isError: boolean) {
|
||||
statusMsg.value = msg
|
||||
@@ -113,86 +119,432 @@ async function saveService(svc: ServicePricing) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// ── TollGate (paid WiFi on the OpenWrt gateway) ──
|
||||
interface TollGateStatus {
|
||||
installed: boolean
|
||||
enabled?: boolean
|
||||
step_size_ms?: number
|
||||
price_per_step?: number
|
||||
min_steps?: number
|
||||
mint_url?: string
|
||||
}
|
||||
|
||||
const tollgate = ref<TollGateStatus | null>(null)
|
||||
const tollgateChecked = ref(false)
|
||||
const tollgateSaving = ref(false)
|
||||
const tollgatePrice = ref(10)
|
||||
const tollgateEnabled = ref(false)
|
||||
|
||||
const tollgateUnit = computed(() => {
|
||||
const ms = tollgate.value?.step_size_ms || 60_000
|
||||
if (ms === 3_600_000) return 'hour'
|
||||
if (ms === 60_000) return 'minute'
|
||||
if (ms === 1000) return 'second'
|
||||
return `${ms.toLocaleString()} ms`
|
||||
})
|
||||
|
||||
async function loadTollgate() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ tollgate?: TollGateStatus }>({ method: 'openwrt.get-status' })
|
||||
tollgate.value = res.tollgate || null
|
||||
tollgateEnabled.value = !!res.tollgate?.enabled
|
||||
tollgatePrice.value = Math.max(1, res.tollgate?.price_per_step || 10)
|
||||
} catch {
|
||||
// No gateway configured (or unreachable) — the card shows the setup hint.
|
||||
tollgate.value = null
|
||||
} finally {
|
||||
tollgateChecked.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTollgate() {
|
||||
if (tollgatePrice.value < 1) tollgatePrice.value = 1
|
||||
tollgateSaving.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'openwrt.provision-tollgate',
|
||||
params: {
|
||||
enabled: tollgateEnabled.value,
|
||||
// Real backend reads price_sats; the mock reads price_per_step —
|
||||
// send both so the same call works against either.
|
||||
price_sats: tollgatePrice.value,
|
||||
price_per_step: tollgatePrice.value,
|
||||
step_size_ms: tollgate.value?.step_size_ms || 60_000,
|
||||
min_steps: tollgate.value?.min_steps || 1,
|
||||
},
|
||||
})
|
||||
showStatus(
|
||||
tollgateEnabled.value
|
||||
? `TollGate WiFi is charging ${tollgatePrice.value} sats per ${tollgateUnit.value}.`
|
||||
: 'TollGate WiFi is now free.',
|
||||
false,
|
||||
)
|
||||
void loadTollgate()
|
||||
} catch (e) {
|
||||
showStatus(e instanceof Error ? e.message : 'Failed to save TollGate settings', true)
|
||||
} finally {
|
||||
tollgateSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard state ──
|
||||
type ProfitSource = 'content_sale' | 'routing_fee' | 'streaming_revenue'
|
||||
|
||||
interface ProfitEntry {
|
||||
source: ProfitSource
|
||||
amount_sats: number
|
||||
timestamp: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ProfitsSummary {
|
||||
total_sats: number
|
||||
content_sales_sats: number
|
||||
routing_fees_sats: number
|
||||
streaming_revenue_sats?: number
|
||||
recent?: ProfitEntry[]
|
||||
}
|
||||
|
||||
interface SessionsSummary {
|
||||
total_active: number
|
||||
total_revenue_sats: number
|
||||
revenue_by_service: Record<string, number>
|
||||
}
|
||||
|
||||
const profits = ref<ProfitsSummary | null>(null)
|
||||
const sessionsSummary = ref<SessionsSummary | null>(null)
|
||||
const dashboardLoading = ref(true)
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboardLoading.value = true
|
||||
try {
|
||||
const [p, s] = await Promise.all([
|
||||
rpcClient.call<ProfitsSummary>({ method: 'wallet.networking-profits' }).catch(() => null),
|
||||
rpcClient.call<SessionsSummary>({ method: 'streaming.list-sessions' }).catch(() => null),
|
||||
])
|
||||
profits.value = p
|
||||
sessionsSummary.value = s
|
||||
} finally {
|
||||
dashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSats(n: number | undefined | null): string {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
// 7 daily buckets, oldest → newest, one series per earning source. A week of
|
||||
// daily totals is the sweet spot here: earnings are sparse events (unlike the
|
||||
// second-by-second system metrics on Monitoring), so finer buckets would just
|
||||
// draw noise, and a longer window would flatten a new node's first sats.
|
||||
const DAY_MS = 86_400_000
|
||||
const WINDOW_DAYS = 7
|
||||
|
||||
const dayLabels = computed(() => {
|
||||
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const labels: string[] = []
|
||||
for (let i = WINDOW_DAYS - 1; i >= 0; i--) {
|
||||
labels.push(names[new Date(Date.now() - i * DAY_MS).getDay()] as string)
|
||||
}
|
||||
return labels
|
||||
})
|
||||
|
||||
function bucketize(source: ProfitSource): number[] {
|
||||
const buckets = new Array(WINDOW_DAYS).fill(0)
|
||||
const now = Date.now()
|
||||
for (const e of profits.value?.recent || []) {
|
||||
if (e.source !== source) continue
|
||||
const t = Date.parse(e.timestamp)
|
||||
if (Number.isNaN(t)) continue
|
||||
const age = Math.floor((now - t) / DAY_MS)
|
||||
if (age < 0 || age >= WINDOW_DAYS) continue
|
||||
buckets[WINDOW_DAYS - 1 - age] += e.amount_sats
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
const earningsDatasets = computed<ChartDataset[]>(() => [
|
||||
{ label: 'Streaming', data: bucketize('streaming_revenue'), color: '#f97316' },
|
||||
{ label: 'Content sales', data: bucketize('content_sale'), color: '#3b82f6' },
|
||||
{ label: 'Routing fees', data: bucketize('routing_fee'), color: '#a78bfa' },
|
||||
])
|
||||
|
||||
const hasRecentEarnings = computed(() =>
|
||||
earningsDatasets.value.some((d) => d.data.some((v) => v > 0)),
|
||||
)
|
||||
|
||||
// Revenue-by-service bars, largest first, scaled to the biggest earner.
|
||||
const serviceBars = computed(() => {
|
||||
const by = sessionsSummary.value?.revenue_by_service || {}
|
||||
const entries = Object.entries(by).sort((a, b) => b[1] - a[1])
|
||||
const max = entries[0]?.[1] || 1
|
||||
return entries.map(([id, sats]) => ({
|
||||
id,
|
||||
name: services.value.find((s) => s.service_id === id)?.name || id,
|
||||
sats,
|
||||
pct: Math.max(4, Math.round((sats / max) * 100)),
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Chart sizing (same measure-the-card approach as Monitoring) ──
|
||||
const chartCard = ref<HTMLElement | null>(null)
|
||||
const chartWidth = ref(600)
|
||||
|
||||
function updateChartWidth() {
|
||||
chartWidth.value = Math.max(280, (chartCard.value?.clientWidth || 640) - 40)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadTollgate()
|
||||
void loadDashboard()
|
||||
updateChartWidth()
|
||||
window.addEventListener('resize', updateChartWidth)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateChartWidth)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits — Settings</h1>
|
||||
<p class="text-white/70">
|
||||
Control what your node charges other peers for. By default everything is shared for
|
||||
free — turn a service on to start earning sats (ecash) for it. Payments are collected
|
||||
as Cashu tokens through your node's wallet.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<h1 class="text-3xl font-bold text-white">Networking Profits</h1>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMsg"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMsg }}
|
||||
<!-- Tabs: Dashboard | Configure -->
|
||||
<div class="flex gap-1 mb-6 border-b border-white/10">
|
||||
<button
|
||||
@click="tab = 'dashboard'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="tab === 'dashboard' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'configure'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="tab === 'configure' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Everything-free reassurance banner -->
|
||||
<div
|
||||
v-if="!loading && allFree"
|
||||
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
|
||||
>
|
||||
<span class="text-xl">✓</span>
|
||||
<p class="text-sm text-green-200">
|
||||
Everything is free. Your node isn't charging for anything — enable a service below to
|
||||
start earning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services…</div>
|
||||
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
|
||||
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
|
||||
<!-- ============ DASHBOARD ============ -->
|
||||
<div v-show="tab === 'dashboard'">
|
||||
<div v-if="dashboardLoading" class="glass-card p-6 text-white/60 text-sm">Loading earnings…</div>
|
||||
<template v-else>
|
||||
<!-- Stat tiles -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Total earned</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatSats(profits?.total_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats, all time</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
|
||||
{{ svc.enabled ? 'Paid' : 'Free' }}
|
||||
</span>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Streaming</p>
|
||||
<p class="text-2xl font-bold text-orange-400">{{ formatSats(profits?.streaming_revenue_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats from paid services</p>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Content sales</p>
|
||||
<p class="text-2xl font-bold text-blue-400">{{ formatSats(profits?.content_sales_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats from ecash sales</p>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Routing fees</p>
|
||||
<p class="text-2xl font-bold text-violet-400">{{ formatSats(profits?.routing_fees_sats) }}</p>
|
||||
<p class="text-xs text-white/40">sats from Lightning</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
|
||||
<label class="text-xs text-white/50 block mb-1">Price</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="svc.price_per_step"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="!svc.enabled"
|
||||
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
|
||||
<!-- Earnings chart -->
|
||||
<div ref="chartCard" class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h3 class="text-sm font-medium text-white/80">Earnings — last 7 days</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<span v-for="d in earningsDatasets" :key="d.label" class="flex items-center gap-1.5 text-xs text-white/60">
|
||||
<span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: d.color }"></span>
|
||||
{{ d.label }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="saveService(svc)"
|
||||
:disabled="savingId === svc.service_id"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<LineChart
|
||||
v-if="hasRecentEarnings"
|
||||
:datasets="earningsDatasets"
|
||||
:labels="dayLabels"
|
||||
:width="chartWidth"
|
||||
:height="200"
|
||||
/>
|
||||
<div v-else class="py-10 text-center text-white/40 text-sm">
|
||||
No earnings in the last 7 days — enable a paid service under Configure to start earning.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions + revenue by service -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="glass-card p-5">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide mb-1">Active sessions</p>
|
||||
<p class="text-4xl font-bold text-white mb-1">{{ sessionsSummary?.total_active ?? 0 }}</p>
|
||||
<p class="text-xs text-white/40">peers currently paying for services</p>
|
||||
<p class="text-sm text-white/70 mt-4">
|
||||
Session revenue:
|
||||
<span class="text-white font-medium">{{ formatSats(sessionsSummary?.total_revenue_sats) }} sats</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="glass-card p-5 lg:col-span-2">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">Revenue by service</h3>
|
||||
<div v-if="serviceBars.length === 0" class="py-6 text-center text-white/40 text-sm">
|
||||
No service revenue yet.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="bar in serviceBars" :key="bar.id">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-white/80">{{ bar.name }}</span>
|
||||
<span class="text-white/50">{{ formatSats(bar.sats) }} sats</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-orange-500/70" :style="{ width: bar.pct + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ============ CONFIGURE ============ -->
|
||||
<div v-show="tab === 'configure'">
|
||||
<!-- Intro copy, boxed so it reads as its own thing rather than page dressing -->
|
||||
<div class="glass-card p-4 mb-6">
|
||||
<p class="text-sm text-white/70 leading-relaxed">
|
||||
Control what your node charges other peers for. By default everything is shared for
|
||||
free — turn a service on to start earning sats (ecash) for it. Payments are collected
|
||||
as Cashu tokens through your node's wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMsg"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMsg }}
|
||||
</div>
|
||||
|
||||
<!-- Everything-free reassurance banner -->
|
||||
<div
|
||||
v-if="!loading && allFree"
|
||||
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
|
||||
>
|
||||
<span class="text-xl">✓</span>
|
||||
<p class="text-sm text-green-200">
|
||||
Everything is free. Your node isn't charging for anything — enable a service below to
|
||||
start earning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services…</div>
|
||||
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
|
||||
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
|
||||
{{ svc.enabled ? 'Paid' : 'Free' }}
|
||||
</span>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
|
||||
<label class="text-xs text-white/50 block mb-1">Price</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="svc.price_per_step"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="!svc.enabled"
|
||||
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
|
||||
</div>
|
||||
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="saveService(svc)"
|
||||
:disabled="savingId === svc.service_id"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TollGate: paid WiFi on the OpenWrt gateway -->
|
||||
<div v-if="tollgateChecked" class="glass-card p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-white">TollGate WiFi</h2>
|
||||
<p class="text-sm text-white/60 mt-0.5">
|
||||
Sell WiFi access on your OpenWrt gateway — visitors pay per {{ tollgateUnit }} in ecash.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="tollgate?.installed" class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs" :class="tollgateEnabled ? 'text-orange-400' : 'text-white/40'">
|
||||
{{ tollgateEnabled ? 'Paid' : 'Free' }}
|
||||
</span>
|
||||
<ToggleSwitch :model-value="tollgateEnabled" @update:model-value="(v) => (tollgateEnabled = v)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tollgate?.installed" class="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !tollgateEnabled }">
|
||||
<label class="text-xs text-white/50 block mb-1">Price</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="tollgatePrice"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="!tollgateEnabled"
|
||||
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<span class="text-sm text-white/70">sats per {{ tollgateUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="saveTollgate"
|
||||
:disabled="tollgateSaving"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ tollgateSaving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<p class="text-sm text-white/50 flex-1">
|
||||
TollGate isn't set up yet — it needs an OpenWrt gateway paired with this node.
|
||||
</p>
|
||||
<button
|
||||
@click="router.push('/dashboard/server/openwrt')"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm shrink-0"
|
||||
>
|
||||
Set up gateway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
<template>
|
||||
<!-- Node Visibility -->
|
||||
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
|
||||
<p class="text-white/70 text-sm">
|
||||
Make your node publicly discoverable. When enabled, anyone on the Nostr
|
||||
network can find your node and request a connection — requests always
|
||||
wait for your approval and join as a Peer, never trusted.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="visibilityLoading" class="shrink-0">
|
||||
<svg class="animate-spin h-5 w-5 text-white/40" 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>
|
||||
<div class="mb-4 shrink-0">
|
||||
<div class="flex items-center sm:items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white sm:mb-2">{{ t('web5.nodeVisibility') }}</h2>
|
||||
<!-- Desktop: description beside the icon, as before -->
|
||||
<p class="hidden sm:block text-white/70 text-sm">
|
||||
Make your node publicly discoverable. When enabled, anyone on the Nostr
|
||||
network can find your node and request a connection — requests always
|
||||
wait for your approval and join as a Peer, never trusted.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="visibilityLoading" class="shrink-0">
|
||||
<svg class="animate-spin h-5 w-5 text-white/40" 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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: description stacked under the icon + title -->
|
||||
<p class="sm:hidden mt-3 text-white/70 text-sm">
|
||||
Make your node publicly discoverable. When enabled, anyone on the Nostr
|
||||
network can find your node and request a connection — requests always
|
||||
wait for your approval and join as a Peer, never trusted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Enable switch -->
|
||||
@@ -57,16 +66,7 @@
|
||||
|
||||
<!-- Discoverable nodes -->
|
||||
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium text-white">Discoverable nodes</p>
|
||||
<button
|
||||
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discovering"
|
||||
@click="discoverNodes"
|
||||
>
|
||||
{{ discovering ? 'Searching…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-white mb-2">Discoverable nodes</p>
|
||||
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
|
||||
Searching relays…
|
||||
</div>
|
||||
@@ -95,10 +95,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
|
||||
{{ t('web5.discoverableWarning') }}
|
||||
</p>
|
||||
<!-- Refresh — full-width card action on every screen size -->
|
||||
<button
|
||||
v-if="discoverEnabled"
|
||||
class="mt-4 w-full glass-button rounded-lg py-2.5 text-sm font-medium text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discovering"
|
||||
@click="discoverNodes"
|
||||
>
|
||||
{{ discovering ? 'Searching…' : 'Refresh' }}
|
||||
</button>
|
||||
|
||||
<PeerRequestModal
|
||||
:show="requestModalTarget !== null"
|
||||
|
||||